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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Foundation
import Observation

@Observable
@MainActor
final class WorkoutExerciseGuidanceViewModel {
private let service: ExerciseGuidanceReading?

private(set) var firstTimeStatuses: [String: ExerciseFirstTimeStatusResponse] = [:]
private(set) var firstTimeStatusErrors: [String: String] = [:]
private(set) var loadingFirstTimeStatusExerciseIDs: Set<String> = []
private(set) var exerciseGuides: [String: ExerciseGuideResponse] = [:]
private(set) var exerciseGuideErrors: [String: String] = [:]
private(set) var loadingExerciseGuideIDs: Set<String> = []

init(service: ExerciseGuidanceReading?) {
self.service = service
}

func shouldShowFirstExercisePrompt(for exercise: WorkoutSessionExercise) -> Bool {
guard let status = firstTimeStatuses[exercise.id], status.exerciseId == exercise.id else {
return !exercise.hasPreviousPerformance
}
return status.isFirstTime && status.guideAvailable
}

func loadFirstTimeStatus(for exerciseID: String, in plan: WorkoutSessionPlan) async {
guard let service,
plan.exercises.contains(where: { $0.id == exerciseID }),
!loadingFirstTimeStatusExerciseIDs.contains(exerciseID) else { return }

loadingFirstTimeStatusExerciseIDs.insert(exerciseID)
defer { loadingFirstTimeStatusExerciseIDs.remove(exerciseID) }

do {
let response = try await service.firstTimeStatus(exerciseId: exerciseID)
guard response.exerciseId == exerciseID else {
throw APIClientError.invalidResponse
}

firstTimeStatuses[exerciseID] = response
firstTimeStatusErrors.removeValue(forKey: exerciseID)
} catch {
guard !Task.isCancelled else { return }
firstTimeStatusErrors[exerciseID] = Self.errorMessage(
for: error,
fallback: "첫 기록 상태를 불러오지 못했어요."
)
}
}

func exerciseGuide(for exerciseID: String) -> ExerciseGuideResponse? {
exerciseGuides[exerciseID]
}

func exerciseGuideError(for exerciseID: String) -> String? {
exerciseGuideErrors[exerciseID]
}

func isLoadingExerciseGuide(for exerciseID: String) -> Bool {
loadingExerciseGuideIDs.contains(exerciseID)
}

func loadExerciseGuide(for exerciseID: String, in plan: WorkoutSessionPlan) async {
guard let service,
plan.exercises.contains(where: { $0.id == exerciseID }),
!loadingExerciseGuideIDs.contains(exerciseID) else { return }

loadingExerciseGuideIDs.insert(exerciseID)
defer { loadingExerciseGuideIDs.remove(exerciseID) }

do {
let response = try await service.guide(exerciseId: exerciseID)
guard response.exerciseId == exerciseID else {
throw APIClientError.invalidResponse
}

exerciseGuides[exerciseID] = response
exerciseGuideErrors.removeValue(forKey: exerciseID)
} catch {
guard !Task.isCancelled else { return }
exerciseGuideErrors[exerciseID] = Self.errorMessage(
for: error,
fallback: "자세 가이드를 불러오지 못했어요."
)
}
}

private static func errorMessage(for error: Error, fallback: String) -> String {
if let apiError = error as? APIClientError,
case let .server(_, detail) = apiError {
return detail.message
}
return fallback
}
}
118 changes: 101 additions & 17 deletions GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import AVKit
import SwiftUI

struct WorkoutExerciseGuideScreen: View {
let exercise: WorkoutSessionExercise
let guide: ExerciseGuideResponse?
let guideError: String?
let isLoadingGuide: Bool
let onRetryGuide: () -> Void
let onClose: () -> Void

init(
exercise: WorkoutSessionExercise,
guide: ExerciseGuideResponse? = nil,
guideError: String? = nil,
isLoadingGuide: Bool = false,
onRetryGuide: @escaping () -> Void = {},
onClose: @escaping () -> Void
) {
self.exercise = exercise
self.guide = guide
self.guideError = guideError
self.isLoadingGuide = isLoadingGuide
self.onRetryGuide = onRetryGuide
self.onClose = onClose
}

var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 19) {
Expand All @@ -19,21 +40,29 @@ struct WorkoutExerciseGuideScreen: View {

private var videoHero: some View {
ZStack {
Image(exercise.imageAssetName)
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 300)
.clipped()
if let mediaURL = validMediaURL {
WorkoutGuideVideoPlayer(url: mediaURL)
.frame(maxWidth: .infinity)
.frame(height: 300)
.clipped()
.accessibilityIdentifier("workout.guide.video-player")
} else {
Image(exercise.imageAssetName)
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 300)
.clipped()

Text("영상 준비 중")
.font(GymupTypography.uiBold)
.foregroundStyle(GymupColors.primaryText)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(GymupColors.canvas.opacity(0.85), in: Capsule())
.accessibilityLabel("자세 영상은 준비 중이에요. 아래 자세 설명을 확인해 주세요.")
.accessibilityIdentifier("workout.guide.video-unavailable")
Text("영상 준비 중")
.font(GymupTypography.uiBold)
.foregroundStyle(GymupColors.primaryText)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(GymupColors.canvas.opacity(0.85), in: Capsule())
.accessibilityLabel("자세 영상은 준비 중이에요. 아래 자세 설명을 확인해 주세요.")
.accessibilityIdentifier("workout.guide.video-unavailable")
}
}
.frame(height: 300)
.overlay(alignment: .topTrailing) {
Expand Down Expand Up @@ -63,6 +92,16 @@ struct WorkoutExerciseGuideScreen: View {
.foregroundStyle(GymupColors.secondaryText)
}

if let guideError {
WorkoutSessionRecordRetryBanner(
message: guideError,
isLoading: isLoadingGuide,
retryAccessibilityLabel: "자세 가이드 다시 시도",
retryAccessibilityIdentifier: "workout.guide.retry",
onRetry: onRetryGuide
)
}

muscleChips
keySteps
commonMistakes
Expand All @@ -74,7 +113,7 @@ struct WorkoutExerciseGuideScreen: View {

private var muscleChips: some View {
HStack(spacing: 8) {
ForEach(Array(exercise.guideMuscleGroups.enumerated()), id: \.offset) { index, muscle in
ForEach(Array(displayedMuscles.enumerated()), id: \.offset) { index, muscle in
Text(muscle)
.font(GymupTypography.uiMedium)
.foregroundStyle(index == 0 ? .white : GymupColors.primaryText)
Expand All @@ -96,7 +135,7 @@ struct WorkoutExerciseGuideScreen: View {

private var keySteps: some View {
VStack(spacing: 0) {
ForEach(Array(exercise.formSteps.prefix(3).enumerated()), id: \.offset) { index, step in
ForEach(Array(displayedKeyPoints.prefix(3).enumerated()), id: \.offset) { index, step in
HStack(alignment: .center, spacing: 14) {
Text(String(format: "%02d", index + 1))
.font(.custom("BarlowCondensed-ExtraBold", size: 18))
Expand All @@ -122,7 +161,7 @@ struct WorkoutExerciseGuideScreen: View {
.tracking(0.16)
.foregroundStyle(GymupColors.critical)

ForEach(Array(exercise.commonMistakes.prefix(2).enumerated()), id: \.offset) { _, mistake in
ForEach(Array(displayedCommonMistakes.prefix(2).enumerated()), id: \.offset) { _, mistake in
HStack(spacing: 10) {
Image("R3IconClose")
.resizable()
Expand All @@ -149,4 +188,49 @@ struct WorkoutExerciseGuideScreen: View {
.accessibilityIdentifier("workout.guide.confirm")
.background(GymupColors.canvas)
}

private var validMediaURL: URL? {
guard let value = guide?.mediaUrl,
let url = URL(string: value),
url.scheme?.lowercased() == "https",
url.host != nil else { return nil }
return url
}

private var displayedMuscles: [String] {
guide?.targetMuscles.isEmpty == false ? guide?.targetMuscles ?? [] : exercise.guideMuscleGroups
}

private var displayedKeyPoints: [String] {
guide?.keyPoints.isEmpty == false ? guide?.keyPoints ?? [] : exercise.formSteps
}

private var displayedCommonMistakes: [String] {
guide?.commonMistakes.isEmpty == false ? guide?.commonMistakes ?? [] : exercise.commonMistakes
}
}

private struct WorkoutGuideVideoPlayer: View {
let url: URL
@State private var player: AVPlayer?

var body: some View {
Group {
if let player {
VideoPlayer(player: player)
} else {
GymupColors.canvas
.overlay { ProgressView().tint(GymupColors.primaryText) }
}
}
.onAppear {
guard player == nil else { return }
player = AVPlayer(url: url)
}
.onChange(of: url) { _, newURL in
player?.pause()
player = AVPlayer(url: newURL)
}
.onDisappear { player?.pause() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import UIKit

extension WorkoutSessionView {
func loadPreviousRecord(for exerciseID: String) async {
await viewModel.loadPreviousRecord(for: exerciseID)
async let previousRecord: Void = viewModel.loadPreviousRecord(for: exerciseID)
async let firstTimeStatus: Void = viewModel.exerciseGuidance.loadFirstTimeStatus(
for: exerciseID,
in: viewModel.plan
)
_ = await (previousRecord, firstTimeStatus)
guard viewModel.currentExercise?.id == exerciseID else { return }
showFirstExercisePromptIfNeeded()
}
Expand Down Expand Up @@ -227,7 +232,7 @@ extension WorkoutSessionView {

func showFirstExercisePromptIfNeeded() {
guard let exercise = viewModel.currentExercise,
!exercise.hasPreviousPerformance,
viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise),
!promptedExerciseIDs.contains(exercise.id) else { return }
promptedExerciseIDs.insert(exercise.id)
isShowingFirstExercisePrompt = true
Expand Down
22 changes: 18 additions & 4 deletions GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ struct WorkoutSessionView: View {

@MainActor
init(routineID: String, historyStore: HistoryStore) {
let exerciseReadService = Self.makePreviousRecordService()
_viewModel = State(
initialValue: WorkoutSessionViewModel(
routineID: routineID,
historyStore: historyStore,
previousRecordService: Self.makePreviousRecordService()
previousRecordService: exerciseReadService,
exerciseGuidanceService: exerciseReadService
)
)
self.historyStore = historyStore
Expand All @@ -45,12 +47,14 @@ struct WorkoutSessionView: View {
serverWorkoutID: String? = nil,
startedAt: Date = .now
) {
let exerciseReadService = Self.makePreviousRecordService()
_viewModel = State(
initialValue: WorkoutSessionViewModel(
plan: plan,
startedAt: startedAt,
historyStore: historyStore,
previousRecordService: Self.makePreviousRecordService(),
previousRecordService: exerciseReadService,
exerciseGuidanceService: exerciseReadService,
serverWorkoutID: serverWorkoutID,
progressService: serverWorkoutID == nil ? nil : Self.makeProgressService(),
nextSetService: serverWorkoutID == nil ? nil : Self.makeWorkoutReadService()
Expand Down Expand Up @@ -142,6 +146,14 @@ struct WorkoutSessionView: View {
if let exercise = viewModel.currentExercise {
WorkoutExerciseGuideScreen(
exercise: exercise,
guide: viewModel.exerciseGuidance.exerciseGuide(for: exercise.id),
guideError: viewModel.exerciseGuidance.exerciseGuideError(for: exercise.id),
isLoadingGuide: viewModel.exerciseGuidance.isLoadingExerciseGuide(for: exercise.id),
onRetryGuide: {
Task {
await viewModel.exerciseGuidance.loadExerciseGuide(for: exercise.id, in: viewModel.plan)
}
},
onClose: { isShowingCurrentGuide = false }
)
}
Expand Down Expand Up @@ -198,14 +210,16 @@ struct WorkoutSessionView: View {
}
.onChange(of: viewModel.currentExerciseIndex) { _, _ in
isShowingCurrentGuide = false
showFirstExercisePromptIfNeeded()
}
.onChange(of: isShowingCurrentGuide) { _, isShowing in
guard isShowing, let exerciseID = viewModel.currentExercise?.id else { return }
Task { await viewModel.exerciseGuidance.loadExerciseGuide(for: exerciseID, in: viewModel.plan) }
}
.onChange(of: viewModel.finishedAt) { _, finishedAt in
guard finishedAt != nil else { return }
settings.cancelRestNotification(sessionID: liveActivitySessionID)
Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) }
}
.task { showFirstExercisePromptIfNeeded() }
.task(id: "\(viewModel.hasStarted)-\(viewModel.currentExercise?.id ?? "")") {
guard viewModel.hasStarted, let exerciseID = viewModel.currentExercise?.id else { return }
await loadPreviousRecord(for: exerciseID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class WorkoutSessionViewModel {
private let historyStore: HistoryStore
private let goalSettings: OnboardingStoredSettings?
private let previousRecordService: ExerciseReadAPIService?
let exerciseGuidance: WorkoutExerciseGuidanceViewModel
let nextSetService: WorkoutNextSetReading?
let progressService: WorkoutSessionProgressing?
private(set) var previousRecordErrors: [String: String] = [:]
Expand Down Expand Up @@ -44,6 +45,7 @@ final class WorkoutSessionViewModel {
historyStore: HistoryStore? = nil,
goalSettings: OnboardingStoredSettings? = .load(),
previousRecordService: ExerciseReadAPIService? = nil,
exerciseGuidanceService: ExerciseGuidanceReading? = nil,
progressService: WorkoutSessionProgressing? = nil,
nextSetService: WorkoutNextSetReading? = nil
) {
Expand All @@ -53,6 +55,7 @@ final class WorkoutSessionViewModel {
self.historyStore = historyStore ?? HistoryStore()
self.goalSettings = goalSettings
self.previousRecordService = previousRecordService
exerciseGuidance = WorkoutExerciseGuidanceViewModel(service: exerciseGuidanceService)
self.progressService = progressService
self.nextSetService = nextSetService
serverWorkoutID = nil
Expand All @@ -65,6 +68,7 @@ final class WorkoutSessionViewModel {
historyStore: HistoryStore? = nil,
goalSettings: OnboardingStoredSettings? = .load(),
previousRecordService: ExerciseReadAPIService? = nil,
exerciseGuidanceService: ExerciseGuidanceReading? = nil,
serverWorkoutID: String? = nil,
progressService: WorkoutSessionProgressing? = nil,
nextSetService: WorkoutNextSetReading? = nil
Expand All @@ -75,6 +79,7 @@ final class WorkoutSessionViewModel {
self.historyStore = historyStore ?? HistoryStore()
self.goalSettings = goalSettings
self.previousRecordService = previousRecordService
exerciseGuidance = WorkoutExerciseGuidanceViewModel(service: exerciseGuidanceService)
self.serverWorkoutID = serverWorkoutID
self.progressService = progressService
self.nextSetService = nextSetService
Expand Down
Loading
Loading