Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion GYMUP/Sources/Features/Routine/RoutineCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import SwiftUI

struct RoutineCard: View {
let routine: RoutinePlan
var isStarting = false
var isStartDisabled = false
let onOpen: () -> Void
let onStart: () -> Void

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions GYMUP/Sources/Features/Routine/RoutineDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions GYMUP/Sources/Features/Routine/RoutineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions GYMUP/Sources/Features/Routine/RoutineRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -16,6 +17,7 @@ enum RoutineRoute: Hashable {
case .editAndAddExercise: "루틴 편집"
case .emptyStart: "빈 루틴"
case .workout: "운동"
case .serverWorkout: "운동"
}
}
}
Expand Down
139 changes: 131 additions & 8 deletions GYMUP/Sources/Features/Routine/RoutineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -17,7 +19,13 @@ struct RoutineView: View {
requestedExerciseAddRoutineID: Binding<String?> = .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
Expand Down Expand Up @@ -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) }
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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()
Expand All @@ -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<Bool> {
Binding(
get: { routineSyncFailure != nil },
set: { if !$0 { routineSyncFailure = nil } }
)
}

var isWorkoutStartFailurePresented: Binding<Bool> {
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,
Expand All @@ -160,7 +277,7 @@ struct RoutineView: View {
)
}

private var header: some View {
var header: some View {
HStack {
Text("루틴")
.font(GymupTypography.homeTitle)
Expand All @@ -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) {
Expand Down
Loading
Loading