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..d20ea01 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? 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..b7c8f56 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() @@ -124,31 +185,87 @@ 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 { onEmptyStartAttempt(id) } else if WorkoutSessionPlan.make(from: routine) == nil { isUnsupportedWorkoutPresented = true } else { - path.append(.workout(id)) +#if DEBUG + 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 + startLocalWorkout(id) +#endif } } - private func openRequestedRoutine() { + func startLocalWorkout(_ id: String) { + guard viewModel.store.routine(id: id) != nil else { return } + path.append(.workout(id)) + } + + var isRoutineSyncFailurePresented: Binding { + Binding( + get: { routineSyncFailure != nil }, + set: { if !$0 { routineSyncFailure = nil } } + ) + } + + var isWorkoutStartFailurePresented: Binding { + Binding( + get: { workoutStartFailure != nil }, + set: { if !$0 { workoutStartFailure = nil } } + ) + } + + static func makeRoutineCreator() -> RoutineCreating? { +#if DEBUG + DebugOnboardingNetworking.makeRoutineService() +#else + nil +#endif + } + + static func makeWorkoutStarter() -> WorkoutStarting? { +#if DEBUG + DebugOnboardingNetworking.makeWorkoutSessionService() +#else + nil +#endif + } + + 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, @@ -160,7 +277,7 @@ struct RoutineView: View { ) } - private var header: some View { + var header: some View { HStack { Text("루틴") .font(GymupTypography.homeTitle) @@ -183,6 +300,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..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 { @@ -6,15 +7,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 } } 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))