From 18821ef969a1e9114a53a9465cc6c2a490396ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Thu, 5 Nov 2026 13:30:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20[#186]=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9A=B4=EB=8F=99=20=EC=8B=9C=EC=9E=91=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Routine/RoutineCard.swift | 14 +- .../Features/Routine/RoutineDetailView.swift | 9 +- .../Features/Routine/RoutineModels.swift | 9 ++ .../Features/Routine/RoutineRoute.swift | 2 + .../Features/Routine/RoutineView.swift | 122 +++++++++++++++- .../Features/Routine/RoutineViewModel.swift | 132 +++++++++++++++++- .../WorkoutSession/WorkoutSessionView.swift | 11 +- .../WorkoutSessionViewModel.swift | 6 +- .../DebugOnboardingNetworking.swift | 24 ++++ .../Networking/RoutineAPIService.swift | 45 ++++++ .../Networking/WorkoutSessionAPIService.swift | 6 + .../WorkoutSessionAPIServiceTests.swift | 9 +- GYMUP/Tests/RoutineViewModelTests.swift | 122 ++++++++++++++++ 13 files changed, 500 insertions(+), 11 deletions(-) diff --git a/GYMUP/Sources/Features/Routine/RoutineCard.swift b/GYMUP/Sources/Features/Routine/RoutineCard.swift index 3b09d2a..7b013a7 100644 --- a/GYMUP/Sources/Features/Routine/RoutineCard.swift +++ b/GYMUP/Sources/Features/Routine/RoutineCard.swift @@ -2,6 +2,8 @@ import SwiftUI struct RoutineCard: View { let routine: RoutinePlan + var isStarting = false + var isStartDisabled = false let onOpen: () -> Void let onStart: () -> Void @@ -50,14 +52,24 @@ struct RoutineCard: View { .buttonStyle(.plain) .accessibilityIdentifier("routine.card.\(routine.id)") Button(action: onStart) { + if isStarting { + ProgressView() + .controlSize(.small) + .tint(.white) + .frame(width: 14.76, height: 14.76) + .frame(width: 36.08, height: 36.08) + .background(GymupColors.accent, in: Circle()) + } else { Image("R1IconPlay") .resizable() .frame(width: 14.76, height: 14.76) .frame(width: 36.08, height: 36.08) .background(GymupColors.accent, in: Circle()) } + } .buttonStyle(.plain) - .accessibilityLabel("\(routine.title) 운동 시작") + .disabled(isStartDisabled) + .accessibilityLabel(isStarting ? "\(routine.title) 서버 저장 중" : "\(routine.title) 운동 시작") .accessibilityIdentifier("routine.start.\(routine.id)") .padding(.trailing, 24) .padding(.bottom, 24) diff --git a/GYMUP/Sources/Features/Routine/RoutineDetailView.swift b/GYMUP/Sources/Features/Routine/RoutineDetailView.swift index 1e28b9c..4552eec 100644 --- a/GYMUP/Sources/Features/Routine/RoutineDetailView.swift +++ b/GYMUP/Sources/Features/Routine/RoutineDetailView.swift @@ -5,17 +5,21 @@ struct RoutineDetailView: View { let onDuplicate: (String) -> Void let onDelete: () -> Void let onStart: () -> Void + let isStarting: Bool + let isStartDisabled: Bool @Environment(\.dismiss) private var dismiss @State private var viewModel: RoutineDetailViewModel init(store: RoutineStore, routineID: String, onEdit: @escaping () -> Void, onDuplicate: @escaping (String) -> Void, onDelete: @escaping () -> Void, - onStart: @escaping () -> Void) { + onStart: @escaping () -> Void, isStarting: Bool = false, isStartDisabled: Bool = false) { _viewModel = State(initialValue: RoutineDetailViewModel(store: store, routineID: routineID)) self.onEdit = onEdit self.onDuplicate = onDuplicate self.onDelete = onDelete self.onStart = onStart + self.isStarting = isStarting + self.isStartDisabled = isStartDisabled } var body: some View { @@ -153,7 +157,8 @@ struct RoutineDetailView: View { GymupButton(title: "편집", variant: .elevated, font: GymupTypography.sectionTitle, height: 50, cornerRadius: 999, action: onEdit) .frame(width: 80) - GymupButton(title: "운동 시작", font: GymupTypography.sectionTitle, + GymupButton(title: isStarting ? "서버 저장 중…" : "운동 시작", isEnabled: !isStartDisabled, + font: GymupTypography.sectionTitle, height: 50, cornerRadius: 999, action: onStart) } .padding(.horizontal, 36) diff --git a/GYMUP/Sources/Features/Routine/RoutineModels.swift b/GYMUP/Sources/Features/Routine/RoutineModels.swift index 43c4cbf..150cac4 100644 --- a/GYMUP/Sources/Features/Routine/RoutineModels.swift +++ b/GYMUP/Sources/Features/Routine/RoutineModels.swift @@ -10,6 +10,7 @@ struct RoutinePlan: Identifiable, Hashable, Codable { let lastPerformed: String let coverAssetName: String let isRecommended: Bool + var serverRoutineId: String? = nil var exercises: [RoutineExercise] = [] var muscleGroups: [String] = [] @@ -72,6 +73,13 @@ final class RoutineStore { func routine(id: String) -> RoutinePlan? { routines.first { $0.id == id } } + @discardableResult + func storeServerRoutineID(_ serverRoutineId: String, for localRoutineID: String) -> Bool { + guard let index = routines.firstIndex(where: { $0.id == localRoutineID }) else { return false } + routines[index].serverRoutineId = serverRoutineId + return true + } + @discardableResult func duplicateRoutine(id: String) -> String? { guard let originalIndex = routines.firstIndex(where: { $0.id == id }) else { return nil } @@ -110,6 +118,7 @@ final class RoutineStore { routines[index].exerciseCount = exercises.count routines[index].exercises = exercises routines[index].summarySetCount = nil + routines[index].serverRoutineId = nil return routineID } diff --git a/GYMUP/Sources/Features/Routine/RoutineRoute.swift b/GYMUP/Sources/Features/Routine/RoutineRoute.swift index 12b55e0..7892877 100644 --- a/GYMUP/Sources/Features/Routine/RoutineRoute.swift +++ b/GYMUP/Sources/Features/Routine/RoutineRoute.swift @@ -7,6 +7,7 @@ enum RoutineRoute: Hashable { case editAndAddExercise(String) case emptyStart(String) case workout(String) + case serverWorkout(String, serverWorkoutID: String, startedAt: Date) var title: String { switch self { @@ -16,6 +17,7 @@ enum RoutineRoute: Hashable { case .editAndAddExercise: "루틴 편집" case .emptyStart: "빈 루틴" case .workout: "운동" + case .serverWorkout: "운동" } } } diff --git a/GYMUP/Sources/Features/Routine/RoutineView.swift b/GYMUP/Sources/Features/Routine/RoutineView.swift index e6a2f3e..f2d6d1f 100644 --- a/GYMUP/Sources/Features/Routine/RoutineView.swift +++ b/GYMUP/Sources/Features/Routine/RoutineView.swift @@ -9,6 +9,8 @@ struct RoutineView: View { @State private var isDuplicateToastVisible = false @State private var isUnsupportedWorkoutPresented = false @State private var duplicateToastVersion = 0 + @State private var routineSyncFailure: RoutineSyncFailure? + @State private var workoutStartFailure: RoutineSyncFailure? @Binding private var requestedRoutineID: String? @Binding private var requestedExerciseAddRoutineID: String? @@ -17,7 +19,13 @@ struct RoutineView: View { requestedExerciseAddRoutineID: Binding = .constant(nil), onEmptyStartAttempt: @escaping (String) -> Void = { _ in }, onDetailRouteChanged: @escaping (Bool) -> Void = { _ in }) { - _viewModel = State(initialValue: RoutineViewModel(store: store)) + _viewModel = State( + initialValue: RoutineViewModel( + store: store, + routineCreator: Self.makeRoutineCreator(), + workoutStarter: Self.makeWorkoutStarter() + ) + ) self.historyStore = historyStore self.onDetailRouteChanged = onDetailRouteChanged self.onEmptyStartAttempt = onEmptyStartAttempt @@ -45,6 +53,8 @@ struct RoutineView: View { } else { RoutineCard( routine: routine, + isStarting: viewModel.preparingServerRoutineID == routine.id, + isStartDisabled: viewModel.isPreparingServerRoutineID, onOpen: { path.append(.detail(routine.id)) }, onStart: { startWorkout(routine.id) } ) @@ -73,7 +83,9 @@ struct RoutineView: View { onDelete: { if path.last == .detail(id) { path.removeLast() } }, - onStart: { startWorkout(id) } + onStart: { startWorkout(id) }, + isStarting: viewModel.preparingServerRoutineID == id, + isStartDisabled: viewModel.isPreparingServerRoutineID ) case .create: routineEditor(routineID: nil) @@ -88,6 +100,18 @@ struct RoutineView: View { } else { GymupEmptyState(symbol: "dumbbell", title: "루틴을 열 수 없어요", message: "루틴 내용을 확인해 주세요.") } + case .serverWorkout(let routineID, let serverWorkoutID, let startedAt): + if let routine = viewModel.store.routine(id: routineID), + let plan = WorkoutSessionPlan.make(from: routine) { + WorkoutSessionView( + plan: plan, + historyStore: historyStore, + serverWorkoutID: serverWorkoutID, + startedAt: startedAt + ) + } else { + GymupEmptyState(symbol: "dumbbell", title: "루틴을 열 수 없어요", message: "루틴 내용을 확인해 주세요.") + } default: RoutinePendingView(route: route) } @@ -98,6 +122,43 @@ struct RoutineView: View { } message: { Text("운동 세트 설정을 확인해 주세요.") } + .confirmationDialog( + "서버에 루틴을 저장하지 못했어요", + isPresented: isRoutineSyncFailurePresented, + titleVisibility: .visible + ) { + Button("다시 시도") { + guard let failure = routineSyncFailure else { return } + routineSyncFailure = nil + startWorkout(failure.localRoutineID) + } + Button("로컬로 시작") { + guard let failure = routineSyncFailure else { return } + routineSyncFailure = nil + startLocalWorkout(failure.localRoutineID) + } + Button("취소", role: .cancel) { + routineSyncFailure = nil + } + } message: { + Text(routineSyncFailure?.message ?? "로컬 루틴은 저장되어 있어요.") + } + .confirmationDialog( + "서버에서 운동을 시작하지 못했어요", + isPresented: isWorkoutStartFailurePresented, + titleVisibility: .visible + ) { + Button("다시 시도") { + guard let failure = workoutStartFailure else { return } + workoutStartFailure = nil + startWorkout(failure.localRoutineID) + } + Button("취소", role: .cancel) { + workoutStartFailure = nil + } + } message: { + Text(workoutStartFailure?.message ?? "잠시 후 다시 시도해 주세요.") + } .overlay(alignment: .bottom) { if isDuplicateToastVisible { RoutineDuplicateToast() @@ -126,15 +187,64 @@ struct RoutineView: View { private func startWorkout(_ id: String) { guard let routine = viewModel.store.routine(id: id) else { return } + guard !viewModel.isPreparingServerRoutineID else { return } if routine.isEmpty || routine.exercises.isEmpty { onEmptyStartAttempt(id) } else if WorkoutSessionPlan.make(from: routine) == nil { isUnsupportedWorkoutPresented = true } else { - path.append(.workout(id)) +#if DEBUG + Task { @MainActor in + switch await viewModel.startServerWorkout(for: id) { + case let .started(serverWorkoutID, startedAt): + path.append(.serverWorkout(id, serverWorkoutID: serverWorkoutID, startedAt: startedAt)) + case let .routineSyncFailed(message): + routineSyncFailure = RoutineSyncFailure(localRoutineID: id, message: message) + case let .workoutStartFailed(message): + workoutStartFailure = RoutineSyncFailure(localRoutineID: id, message: message) + } + } +#else + startLocalWorkout(id) +#endif } } + private func startLocalWorkout(_ id: String) { + guard viewModel.store.routine(id: id) != nil else { return } + path.append(.workout(id)) + } + + private var isRoutineSyncFailurePresented: Binding { + Binding( + get: { routineSyncFailure != nil }, + set: { if !$0 { routineSyncFailure = nil } } + ) + } + + private var isWorkoutStartFailurePresented: Binding { + Binding( + get: { workoutStartFailure != nil }, + set: { if !$0 { workoutStartFailure = nil } } + ) + } + + private static func makeRoutineCreator() -> RoutineCreating? { +#if DEBUG + DebugOnboardingNetworking.makeRoutineService() +#else + nil +#endif + } + + private static func makeWorkoutStarter() -> WorkoutStarting? { +#if DEBUG + DebugOnboardingNetworking.makeWorkoutSessionService() +#else + nil +#endif + } + private func openRequestedRoutine() { guard let id = requestedRoutineID else { return } requestedRoutineID = nil @@ -183,6 +293,12 @@ struct RoutineView: View { } } +private struct RoutineSyncFailure: Identifiable { + let id = UUID() + let localRoutineID: String + let message: String +} + private struct RoutineDuplicateToast: View { var body: some View { HStack(spacing: 8) { diff --git a/GYMUP/Sources/Features/Routine/RoutineViewModel.swift b/GYMUP/Sources/Features/Routine/RoutineViewModel.swift index 4057582..a3fd1dd 100644 --- a/GYMUP/Sources/Features/Routine/RoutineViewModel.swift +++ b/GYMUP/Sources/Features/Routine/RoutineViewModel.swift @@ -6,15 +6,145 @@ enum RoutineListMode: String, CaseIterable, Identifiable { var id: Self { self } } +enum RoutineServerIDPreparationResult { + case ready + case failed(message: String) +} + +enum RoutineWorkoutStartResult: Equatable { + case started(serverWorkoutID: String, startedAt: Date) + case routineSyncFailed(message: String) + case workoutStartFailed(message: String) +} + @Observable @MainActor final class RoutineViewModel { let store: RoutineStore + private let routineCreator: RoutineCreating? + private let workoutStarter: WorkoutStarting? + private(set) var preparingServerRoutineID: String? + var isPreparingServerRoutineID: Bool { preparingServerRoutineID != nil } var mode = RoutineListMode.mine - init(store: RoutineStore) { self.store = store } + init( + store: RoutineStore, + routineCreator: RoutineCreating? = nil, + workoutStarter: WorkoutStarting? = nil + ) { + self.store = store + self.routineCreator = routineCreator + self.workoutStarter = workoutStarter + } var routines: [RoutinePlan] { mode == .mine ? store.routines : store.routines.filter(\.isRecommended) } + + func prepareServerRoutineID(for localRoutineID: String) async -> RoutineServerIDPreparationResult { + guard !isPreparingServerRoutineID, + let routine = store.routine(id: localRoutineID), + routine.serverRoutineId == nil, + let routineCreator + else { + return .ready + } + + preparingServerRoutineID = localRoutineID + defer { preparingServerRoutineID = nil } + + let request = CreateRoutineRequest( + name: routine.name, + exercises: routine.exercises.enumerated().map { index, exercise in + CreateRoutineExerciseRequest( + exerciseId: exercise.id, + sets: exercise.setCount, + reps: exercise.repetitions, + weight: exercise.weight, + restSeconds: exercise.restSeconds, + order: index + ) + } + ) + + do { + let response = try await routineCreator.createRoutine(request) + guard !response.routineId.isEmpty else { + return .failed(message: "서버에서 루틴 ID를 확인하지 못했어요.") + } + guard let currentRoutine = store.routine(id: localRoutineID), + currentRoutine.name == request.name, + currentRoutine.exercises == routine.exercises else { + return .failed(message: "요청 중 루틴이 변경되었어요. 다시 시도해 주세요.") + } + guard store.storeServerRoutineID(response.routineId, for: localRoutineID) else { + return .failed(message: "서버에서 루틴 ID를 확인하지 못했어요.") + } + return .ready + } catch { + if case let APIClientError.server(_, detail) = error { + return .failed(message: detail.message) + } + return .failed(message: "서버에 루틴을 저장하지 못했어요.") + } + } + + func startServerWorkout( + for localRoutineID: String, + at startedAt: Date = .now, + timeZone: TimeZone = .current + ) async -> RoutineWorkoutStartResult { + guard let routine = store.routine(id: localRoutineID) else { + return .routineSyncFailed(message: "루틴을 찾을 수 없어요. 다시 시도해 주세요.") + } + + if routine.serverRoutineId == nil { + switch await prepareServerRoutineID(for: localRoutineID) { + case .ready: + break + case let .failed(message): + return .routineSyncFailed(message: message) + } + } + + guard let currentRoutine = store.routine(id: localRoutineID), + let serverRoutineID = currentRoutine.serverRoutineId, + !serverRoutineID.isEmpty + else { + return .routineSyncFailed(message: "서버에서 루틴 ID를 확인하지 못했어요.") + } + guard let workoutStarter else { + return .workoutStartFailed(message: "서버 운동 시작 기능을 사용할 수 없어요.") + } + guard !isPreparingServerRoutineID else { + return .workoutStartFailed(message: "요청을 처리 중이에요. 잠시 후 다시 시도해 주세요.") + } + + preparingServerRoutineID = localRoutineID + defer { preparingServerRoutineID = nil } + + let request = StartWorkoutRequest( + routineId: serverRoutineID, + startedAt: Self.iso8601String(from: startedAt), + timezone: timeZone.identifier + ) + + do { + let response = try await workoutStarter.startWorkout(request: request) + guard !response.workoutId.isEmpty else { + return .workoutStartFailed(message: "서버에서 운동 ID를 확인하지 못했어요.") + } + return .started(serverWorkoutID: response.workoutId, startedAt: startedAt) + } catch let APIClientError.server(_, detail) { + return .workoutStartFailed(message: detail.message) + } catch { + return .workoutStartFailed(message: "서버에 운동을 시작하지 못했어요.") + } + } + + private static func iso8601String(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 358c43d..6e20598 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -37,12 +37,19 @@ struct WorkoutSessionView: View { } @MainActor - init(plan: WorkoutSessionPlan, historyStore: HistoryStore) { + init( + plan: WorkoutSessionPlan, + historyStore: HistoryStore, + serverWorkoutID: String? = nil, + startedAt: Date = .now + ) { _viewModel = State( initialValue: WorkoutSessionViewModel( plan: plan, + startedAt: startedAt, historyStore: historyStore, - previousRecordService: Self.makePreviousRecordService() + previousRecordService: Self.makePreviousRecordService(), + serverWorkoutID: serverWorkoutID ) ) self.historyStore = historyStore diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index 1339fbb..a2620ba 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -9,6 +9,7 @@ final class WorkoutSessionViewModel { private(set) var now: Date private(set) var finishedAt: Date? private(set) var notice: String? + private(set) var serverWorkoutID: String? private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? @@ -35,6 +36,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + serverWorkoutID = nil } init( @@ -43,7 +45,8 @@ final class WorkoutSessionViewModel { startsImmediately: Bool = true, historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), - previousRecordService: ExerciseReadAPIService? = nil + previousRecordService: ExerciseReadAPIService? = nil, + serverWorkoutID: String? = nil ) { self.plan = plan self.startedAt = startsImmediately ? startedAt : nil @@ -51,6 +54,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + self.serverWorkoutID = serverWorkoutID } var currentExercise: WorkoutSessionExercise? { diff --git a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift index e8ac5bb..d4136ac 100644 --- a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift +++ b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift @@ -155,5 +155,29 @@ enum DebugOnboardingNetworking { let client = APIClient(environment: environment, tokenProvider: tokenProvider) return ExerciseReadAPIService(client: client) } + + static func makeRoutineService() -> RoutineAPIService? { + 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 RoutineAPIService(client: client) + } + + static func makeWorkoutSessionService() -> WorkoutSessionAPIService? { + 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 WorkoutSessionAPIService(client: client) + } } #endif diff --git a/GYMUP/Sources/Networking/RoutineAPIService.swift b/GYMUP/Sources/Networking/RoutineAPIService.swift index f4d229e..3a33649 100644 --- a/GYMUP/Sources/Networking/RoutineAPIService.swift +++ b/GYMUP/Sources/Networking/RoutineAPIService.swift @@ -1,5 +1,39 @@ import Foundation +struct CreateRoutineExerciseRequest: Codable, Equatable { + let exerciseId: String + let sets: Int + let reps: Int + let weight: Double + let restSeconds: Int + let order: Int +} + +struct CreateRoutineRequest: Codable, Equatable { + let name: String + let exercises: [CreateRoutineExerciseRequest] +} + +struct CreatedRoutineExerciseResponse: Codable, Equatable { + let exerciseId: String + let sets: Int + let reps: Int + let weight: Double + let restSeconds: Int + let order: Int +} + +struct CreateRoutineResponse: Codable, Equatable { + let routineId: String + let name: String + let exercises: [CreatedRoutineExerciseResponse] + let createdAt: String +} + +protocol RoutineCreating { + func createRoutine(_ request: CreateRoutineRequest) async throws -> CreateRoutineResponse +} + struct UpdateRoutinePlaylistRequest: Codable, Equatable { let provider: String let playlistId: String @@ -27,6 +61,15 @@ struct RoutineAPIService { self.client = client } + func createRoutine(_ request: CreateRoutineRequest) async throws -> CreateRoutineResponse { + let endpoint = APIEndpoint( + method: .post, + path: "/api/v1/routines", + expectedStatusCode: 201 + ) + return try await client.send(endpoint, body: request, as: CreateRoutineResponse.self) + } + func updatePlaylist( routineId: String, request: UpdateRoutinePlaylistRequest @@ -39,3 +82,5 @@ struct RoutineAPIService { return try await client.send(endpoint, body: request, as: UpdateRoutinePlaylistResponse.self) } } + +extension RoutineAPIService: RoutineCreating {} diff --git a/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift b/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift index fc65397..3fbdc3d 100644 --- a/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift +++ b/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift @@ -80,6 +80,10 @@ struct StartWorkoutResponse: Codable, Equatable { let startedAt: String } +protocol WorkoutStarting { + func startWorkout(request: StartWorkoutRequest) async throws -> StartWorkoutResponse +} + struct WorkoutSessionAPIService { private let client: APIClient @@ -144,3 +148,5 @@ struct WorkoutSessionAPIService { return try await client.send(endpoint, body: request, as: StartWorkoutResponse.self) } } + +extension WorkoutSessionAPIService: WorkoutStarting {} diff --git a/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift b/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift index 8571ef1..adcbdd7 100644 --- a/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift +++ b/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift @@ -86,6 +86,7 @@ final class WorkoutSessionAPIServiceTests: NetworkingTestCase { XCTAssertEqual(createdSet.rest, .object(["durationSeconds": .integer(60)])) } func testWorkoutStartUsesCreatedStatusAndOpaqueSnapshot() async throws { + var capturedRequest: URLRequest? installJSONResponse( [ "workoutId": "workout-1", @@ -94,7 +95,8 @@ final class WorkoutSessionAPIServiceTests: NetworkingTestCase { "currentExercise": ["exerciseId": "exercise-1"], "startedAt": "2026-10-31T09:00:00Z" ], - statusCode: 201 + statusCode: 201, + recordRequest: { capturedRequest = $0 } ) let request = StartWorkoutRequest( @@ -105,6 +107,11 @@ final class WorkoutSessionAPIServiceTests: NetworkingTestCase { let service = try WorkoutSessionAPIService(client: makeClient(token: "test-token")) let response = try await service.startWorkout(request: request) + XCTAssertEqual(capturedRequest?.httpMethod, "POST") + XCTAssertEqual(capturedRequest?.url?.path, "/api/v1/workouts") + let body = try XCTUnwrap(capturedRequest?.httpBody) + XCTAssertEqual(try JSONDecoder().decode(StartWorkoutRequest.self, from: body), request) + XCTAssertEqual(response.workoutId, "workout-1") XCTAssertEqual(response.status, .inProgress) XCTAssertEqual(response.routineSnapshot, .object(["routineId": .string("routine-1")])) } diff --git a/GYMUP/Tests/RoutineViewModelTests.swift b/GYMUP/Tests/RoutineViewModelTests.swift index 2183164..51cd745 100644 --- a/GYMUP/Tests/RoutineViewModelTests.swift +++ b/GYMUP/Tests/RoutineViewModelTests.swift @@ -14,4 +14,126 @@ final class RoutineViewModelTests: XCTestCase { XCTAssertEqual(viewModel.routines, store.routines) XCTAssertTrue(viewModel.routines.contains(where: \.isEmpty)) } + + @MainActor + func testServerWorkoutUsesCreatedRoutineIDAndKeepsReturnedWorkoutID() async throws { + let store = RoutineStore() + let creator = RoutineCreatorStub(serverRoutineID: "server-routine-1") + let starter = WorkoutStarterStub( + serverWorkoutID: "server-workout-1", + failsFirstRequest: false + ) + let viewModel = RoutineViewModel( + store: store, + routineCreator: creator, + workoutStarter: starter + ) + let startedAt = Date(timeIntervalSince1970: 1_800_000_000) + + let result = await viewModel.startServerWorkout( + for: "upper-a", + at: startedAt, + timeZone: try XCTUnwrap(TimeZone(identifier: "Asia/Seoul")) + ) + + XCTAssertEqual(result, .started(serverWorkoutID: "server-workout-1", startedAt: startedAt)) + XCTAssertEqual(store.routine(id: "upper-a")?.serverRoutineId, "server-routine-1") + let createRequests = await creator.recordedRequests() + let startRequests = await starter.recordedRequests() + XCTAssertEqual(createRequests.count, 1) + XCTAssertEqual(startRequests.count, 1) + XCTAssertEqual(startRequests.first?.routineId, "server-routine-1") + XCTAssertEqual(startRequests.first?.timezone, "Asia/Seoul") + XCTAssertEqual(startRequests.first?.startedAt, "2027-01-15T08:00:00.000Z") + } + + @MainActor + func testWorkoutRetryReusesPersistedRoutineIDAndShowsServerMessage() async { + let store = RoutineStore() + let creator = RoutineCreatorStub(serverRoutineID: "server-routine-1") + let starter = WorkoutStarterStub( + serverWorkoutID: "server-workout-1", + failsFirstRequest: true + ) + let viewModel = RoutineViewModel( + store: store, + routineCreator: creator, + workoutStarter: starter + ) + + let firstResult = await viewModel.startServerWorkout(for: "upper-a") + XCTAssertEqual(firstResult, .workoutStartFailed(message: "세션을 시작하지 못했어요.")) + XCTAssertEqual(store.routine(id: "upper-a")?.serverRoutineId, "server-routine-1") + + let retryResult = await viewModel.startServerWorkout(for: "upper-a") + + guard case .started(serverWorkoutID: "server-workout-1", _) = retryResult else { + return XCTFail("Expected the retry to start the server workout.") + } + let createRequests = await creator.recordedRequests() + let startRequests = await starter.recordedRequests() + XCTAssertEqual(createRequests.count, 1) + XCTAssertEqual(startRequests.map(\.routineId), ["server-routine-1", "server-routine-1"]) + } +} + +private actor RoutineCreatorStub: RoutineCreating { + private let serverRoutineID: String + private var requests: [CreateRoutineRequest] = [] + + init(serverRoutineID: String) { + self.serverRoutineID = serverRoutineID + } + + func createRoutine(_ request: CreateRoutineRequest) async throws -> CreateRoutineResponse { + requests.append(request) + let exercises = request.exercises.map { + CreatedRoutineExerciseResponse( + exerciseId: $0.exerciseId, + sets: $0.sets, + reps: $0.reps, + weight: $0.weight, + restSeconds: $0.restSeconds, + order: $0.order + ) + } + return CreateRoutineResponse( + routineId: serverRoutineID, + name: request.name, + exercises: exercises, + createdAt: "2026-11-05T00:00:00Z" + ) + } + + func recordedRequests() -> [CreateRoutineRequest] { requests } +} + +private actor WorkoutStarterStub: WorkoutStarting { + private let serverWorkoutID: String + private let failsFirstRequest: Bool + private var requests: [StartWorkoutRequest] = [] + + init(serverWorkoutID: String, failsFirstRequest: Bool) { + self.serverWorkoutID = serverWorkoutID + self.failsFirstRequest = failsFirstRequest + } + + func startWorkout(request: StartWorkoutRequest) async throws -> StartWorkoutResponse { + requests.append(request) + if failsFirstRequest && requests.count == 1 { + throw APIClientError.server( + statusCode: 409, + detail: ServerErrorDetail(code: "ACTIVE_WORKOUT_EXISTS", message: "세션을 시작하지 못했어요.") + ) + } + return StartWorkoutResponse( + workoutId: serverWorkoutID, + status: .inProgress, + routineSnapshot: .object(["routineId": .string(request.routineId)]), + currentExercise: .object(["exerciseId": .string("bench")]), + startedAt: request.startedAt + ) + } + + func recordedRequests() -> [StartWorkoutRequest] { requests } } From 695f3892a226a48286239ee6e8703842444aa5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Thu, 5 Nov 2026 13:45:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20[#186]=20SwiftLint=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Routine/RoutineModels.swift | 2 +- .../Features/Routine/RoutineView.swift | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/GYMUP/Sources/Features/Routine/RoutineModels.swift b/GYMUP/Sources/Features/Routine/RoutineModels.swift index 150cac4..d20ea01 100644 --- a/GYMUP/Sources/Features/Routine/RoutineModels.swift +++ b/GYMUP/Sources/Features/Routine/RoutineModels.swift @@ -10,7 +10,7 @@ struct RoutinePlan: Identifiable, Hashable, Codable { let lastPerformed: String let coverAssetName: String let isRecommended: Bool - var serverRoutineId: String? = nil + var serverRoutineId: String? var exercises: [RoutineExercise] = [] var muscleGroups: [String] = [] diff --git a/GYMUP/Sources/Features/Routine/RoutineView.swift b/GYMUP/Sources/Features/Routine/RoutineView.swift index f2d6d1f..66d4e1d 100644 --- a/GYMUP/Sources/Features/Routine/RoutineView.swift +++ b/GYMUP/Sources/Features/Routine/RoutineView.swift @@ -185,7 +185,10 @@ struct RoutineView: View { .onChange(of: requestedExerciseAddRoutineID) { _, _ in openRequestedExerciseAdd() } } - private func startWorkout(_ id: String) { +} + +private extension RoutineView { + func startWorkout(_ id: String) { guard let routine = viewModel.store.routine(id: id) else { return } guard !viewModel.isPreparingServerRoutineID else { return } if routine.isEmpty || routine.exercises.isEmpty { @@ -210,26 +213,26 @@ struct RoutineView: View { } } - private func startLocalWorkout(_ id: String) { + func startLocalWorkout(_ id: String) { guard viewModel.store.routine(id: id) != nil else { return } path.append(.workout(id)) } - private var isRoutineSyncFailurePresented: Binding { + var isRoutineSyncFailurePresented: Binding { Binding( get: { routineSyncFailure != nil }, set: { if !$0 { routineSyncFailure = nil } } ) } - private var isWorkoutStartFailurePresented: Binding { + var isWorkoutStartFailurePresented: Binding { Binding( get: { workoutStartFailure != nil }, set: { if !$0 { workoutStartFailure = nil } } ) } - private static func makeRoutineCreator() -> RoutineCreating? { + static func makeRoutineCreator() -> RoutineCreating? { #if DEBUG DebugOnboardingNetworking.makeRoutineService() #else @@ -237,7 +240,7 @@ struct RoutineView: View { #endif } - private static func makeWorkoutStarter() -> WorkoutStarting? { + static func makeWorkoutStarter() -> WorkoutStarting? { #if DEBUG DebugOnboardingNetworking.makeWorkoutSessionService() #else @@ -245,20 +248,20 @@ struct RoutineView: View { #endif } - private func openRequestedRoutine() { + func openRequestedRoutine() { guard let id = requestedRoutineID else { return } requestedRoutineID = nil if viewModel.store.routine(id: id) != nil { path.append(.detail(id)) } } - private func openRequestedExerciseAdd() { + func openRequestedExerciseAdd() { guard let id = requestedExerciseAddRoutineID else { return } requestedExerciseAddRoutineID = nil guard viewModel.store.routine(id: id) != nil else { return } path = [.editAndAddExercise(id)] } - private func routineEditor(routineID: String?, opensAddExerciseOnAppear: Bool = false) -> some View { + func routineEditor(routineID: String?, opensAddExerciseOnAppear: Bool = false) -> some View { RoutineEditorView( store: viewModel.store, routineID: routineID, @@ -270,7 +273,7 @@ struct RoutineView: View { ) } - private var header: some View { + var header: some View { HStack { Text("루틴") .font(GymupTypography.homeTitle) From 6f935d92aff6a58ff73a43a1dba0616abc11d855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Thu, 5 Nov 2026 13:51:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20[#186]=20Foundation=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GYMUP/Sources/Features/Routine/RoutineViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/GYMUP/Sources/Features/Routine/RoutineViewModel.swift b/GYMUP/Sources/Features/Routine/RoutineViewModel.swift index a3fd1dd..470815e 100644 --- a/GYMUP/Sources/Features/Routine/RoutineViewModel.swift +++ b/GYMUP/Sources/Features/Routine/RoutineViewModel.swift @@ -1,3 +1,4 @@ +import Foundation import Observation enum RoutineListMode: String, CaseIterable, Identifiable { From 154561b34c5c176c0cd5b00633e3798781719cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Thu, 5 Nov 2026 14:20:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20[#186]=20UI=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=9C=EB=B2=84=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Routine/RoutineView.swift | 20 ++++++---- .../UITests/PlateCalculatorFlowUITests.swift | 19 +++++++-- GYMUP/UITests/RoutineSessionFlowUITests.swift | 40 +++++++++++-------- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/GYMUP/Sources/Features/Routine/RoutineView.swift b/GYMUP/Sources/Features/Routine/RoutineView.swift index 66d4e1d..b7c8f56 100644 --- a/GYMUP/Sources/Features/Routine/RoutineView.swift +++ b/GYMUP/Sources/Features/Routine/RoutineView.swift @@ -197,14 +197,18 @@ private extension RoutineView { isUnsupportedWorkoutPresented = true } else { #if DEBUG - Task { @MainActor in - switch await viewModel.startServerWorkout(for: id) { - case let .started(serverWorkoutID, startedAt): - path.append(.serverWorkout(id, serverWorkoutID: serverWorkoutID, startedAt: startedAt)) - case let .routineSyncFailed(message): - routineSyncFailure = RoutineSyncFailure(localRoutineID: id, message: message) - case let .workoutStartFailed(message): - workoutStartFailure = RoutineSyncFailure(localRoutineID: id, message: message) + if ProcessInfo.processInfo.arguments.contains("--uitest-disable-routine-server") { + startLocalWorkout(id) + } else { + Task { @MainActor in + switch await viewModel.startServerWorkout(for: id) { + case let .started(serverWorkoutID, startedAt): + path.append(.serverWorkout(id, serverWorkoutID: serverWorkoutID, startedAt: startedAt)) + case let .routineSyncFailed(message): + routineSyncFailure = RoutineSyncFailure(localRoutineID: id, message: message) + case let .workoutStartFailed(message): + workoutStartFailure = RoutineSyncFailure(localRoutineID: id, message: message) + } } } #else diff --git a/GYMUP/UITests/PlateCalculatorFlowUITests.swift b/GYMUP/UITests/PlateCalculatorFlowUITests.swift index 9c7ff9f..a3f5c10 100644 --- a/GYMUP/UITests/PlateCalculatorFlowUITests.swift +++ b/GYMUP/UITests/PlateCalculatorFlowUITests.swift @@ -4,10 +4,9 @@ final class PlateCalculatorFlowUITests: XCTestCase { func testRestScreenOpensAndClosesPlateCalculator() { let app = XCUIApplication() app.launchArguments = ["-onboarding.completed", "YES"] - app.launchArguments += ["--uitest-ephemeral"] + app.launchArguments += ["--uitest-ephemeral", "--uitest-disable-routine-server"] app.launch() - XCTAssertTrue(app.buttons["루틴"].waitForExistence(timeout: 15)) - app.buttons["루틴"].tap() + openRoutineTab(in: app) let start = app.buttons["routine.start.upper-a"] XCTAssertTrue(start.waitForExistence(timeout: 5)) start.tap() @@ -32,4 +31,18 @@ final class PlateCalculatorFlowUITests: XCTestCase { close.tap() XCTAssertTrue(app.buttons["workout.rest.skip"].waitForExistence(timeout: 5)) } + + private func openRoutineTab(in app: XCUIApplication) { + let routineTab = app.buttons["루틴"] + XCTAssertTrue(routineTab.waitForExistence(timeout: 15)) + routineTab.tap() + + let selectedRoutineTab = app.buttons.matching( + NSPredicate(format: "label == %@ AND selected == true", "루틴") + ).firstMatch + if !selectedRoutineTab.waitForExistence(timeout: 3) { + routineTab.tap() + } + XCTAssertTrue(selectedRoutineTab.waitForExistence(timeout: 5)) + } } diff --git a/GYMUP/UITests/RoutineSessionFlowUITests.swift b/GYMUP/UITests/RoutineSessionFlowUITests.swift index b0222b6..c8eddfa 100644 --- a/GYMUP/UITests/RoutineSessionFlowUITests.swift +++ b/GYMUP/UITests/RoutineSessionFlowUITests.swift @@ -4,11 +4,9 @@ final class RoutineSessionFlowUITests: XCTestCase { func testSelectedRoutineOpensItsFirstExercise() { let app = XCUIApplication() app.launchArguments = ["-onboarding.completed", "YES"] - app.launchArguments += ["--uitest-ephemeral"] + app.launchArguments += ["--uitest-ephemeral", "--uitest-disable-routine-server"] app.launch() - let routineTab = app.buttons["루틴"] - XCTAssertTrue(routineTab.waitForExistence(timeout: 15)) - routineTab.tap() + openRoutineTab(in: app) let start = app.buttons["routine.start.upper-a"] XCTAssertTrue(start.waitForExistence(timeout: 5)) start.tap() @@ -23,12 +21,12 @@ final class RoutineSessionFlowUITests: XCTestCase { func testUnavailableExerciseVideoExplainsStatusAndReturnsToWorkout() { let app = XCUIApplication() - app.launchArguments = ["-onboarding.completed", "YES", "--uitest-ephemeral"] + app.launchArguments = [ + "-onboarding.completed", "YES", "--uitest-ephemeral", "--uitest-disable-routine-server" + ] app.launch() - let routineTab = app.buttons["루틴"] - XCTAssertTrue(routineTab.waitForExistence(timeout: 15)) - routineTab.tap() + openRoutineTab(in: app) let start = app.buttons["routine.start.upper-a"] XCTAssertTrue(start.waitForExistence(timeout: 5)) @@ -51,11 +49,9 @@ final class RoutineSessionFlowUITests: XCTestCase { func testRoutineContainingTimedExerciseCanStart() { let app = XCUIApplication() app.launchArguments = ["-onboarding.completed", "YES"] - app.launchArguments += ["--uitest-ephemeral"] + app.launchArguments += ["--uitest-ephemeral", "--uitest-disable-routine-server"] app.launch() - let routineTab = app.buttons["루틴"] - XCTAssertTrue(routineTab.waitForExistence(timeout: 15)) - routineTab.tap() + openRoutineTab(in: app) let start = app.buttons["routine.start.lower-b"] XCTAssertTrue(start.waitForExistence(timeout: 5)) start.tap() @@ -66,11 +62,9 @@ final class RoutineSessionFlowUITests: XCTestCase { func testTimedPlankCompletesRestAndSavesHistory() { let app = XCUIApplication() app.launchArguments = ["-onboarding.completed", "YES"] - app.launchArguments += ["--uitest-ephemeral"] + app.launchArguments += ["--uitest-ephemeral", "--uitest-disable-routine-server"] app.launch() - let routineTab = app.buttons["루틴"] - XCTAssertTrue(routineTab.waitForExistence(timeout: 15)) - routineTab.tap() + openRoutineTab(in: app) let start = app.buttons["routine.start.lower-b"] XCTAssertTrue(start.waitForExistence(timeout: 5)) @@ -145,6 +139,20 @@ final class RoutineSessionFlowUITests: XCTestCase { } } + private func openRoutineTab(in app: XCUIApplication) { + let routineTab = app.buttons["루틴"] + XCTAssertTrue(routineTab.waitForExistence(timeout: 15)) + routineTab.tap() + + let selectedRoutineTab = app.buttons.matching( + NSPredicate(format: "label == %@ AND selected == true", "루틴") + ).firstMatch + if !selectedRoutineTab.waitForExistence(timeout: 3) { + routineTab.tap() + } + XCTAssertTrue(selectedRoutineTab.waitForExistence(timeout: 5)) + } + private func tapSetCompletion(_ setNumber: Int, expectsRest: Bool, in app: XCUIApplication) { let completeSet = app.buttons["workout.train.primary-action"] XCTAssertTrue(completeSet.waitForExistence(timeout: 10))