From 898f2951e2d64aa210c37b93f2c82276689482c2 Mon Sep 17 00:00:00 2001 From: DG Date: Tue, 13 Aug 2024 17:21:01 +0900 Subject: [PATCH] Feature/exercise db add exercise/add (#90) * CheckAddableExerciseUsecase * RegisterRapidExerciseUsecase * refactor * RapidExerciseDetailViewModel * feat: DI * feat: binding * chore * fix * fix: sorting --- .../CheckAddableExerciseUsecaseTests.swift | 18 ++ .../sources/App/DI/RapidAssembly.swift | 6 +- .../Usecase/CheckAddableExerciseUsecase.swift | 34 ++++ .../RegisterRapidExerciseUsecase.swift | 51 ++++++ .../Form/Main/SelectExerciseViewModel.swift | 3 + .../RapidExerciseDetailView.swift | 160 +++++++++++------- .../RapidExerciseDetailViewModel.swift | 48 ++++++ 7 files changed, 260 insertions(+), 60 deletions(-) create mode 100644 dg-muscle-ios/Tests/Rapid/CheckAddableExerciseUsecaseTests.swift create mode 100644 dg-muscle-ios/sources/Domain/Rapid/Usecase/CheckAddableExerciseUsecase.swift create mode 100644 dg-muscle-ios/sources/Domain/Rapid/Usecase/RegisterRapidExerciseUsecase.swift create mode 100644 dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailViewModel.swift diff --git a/dg-muscle-ios/Tests/Rapid/CheckAddableExerciseUsecaseTests.swift b/dg-muscle-ios/Tests/Rapid/CheckAddableExerciseUsecaseTests.swift new file mode 100644 index 00000000..5bb673ea --- /dev/null +++ b/dg-muscle-ios/Tests/Rapid/CheckAddableExerciseUsecaseTests.swift @@ -0,0 +1,18 @@ +// +// CheckAddableExerciseUsecaseTests.swift +// AppTests +// +// Created by Happymoonday on 8/13/24. +// + +import XCTest +import Domain +import MockData + +final class CheckAddableExerciseUsecaseTests: XCTestCase { + func testExample() { + let usecase = CheckAddableExerciseUsecase() + let exercise = RAPID_EXERCISES[0] + XCTAssertTrue(usecase.implement(exercise: exercise)) + } +} diff --git a/dg-muscle-ios/sources/App/DI/RapidAssembly.swift b/dg-muscle-ios/sources/App/DI/RapidAssembly.swift index 6aaae60e..efe5a22d 100644 --- a/dg-muscle-ios/sources/App/DI/RapidAssembly.swift +++ b/dg-muscle-ios/sources/App/DI/RapidAssembly.swift @@ -32,7 +32,11 @@ public struct RapidAssembly: Assembly { } container.register(RapidExerciseDetailView.self) { (resolver, exercise: RapidExerciseDomain) in - return RapidExerciseDetailView(exercise: exercise) + + let exerciseRepository = resolver.resolve(ExerciseRepository.self)! + + return RapidExerciseDetailView(exercise: exercise, + exerciseRepository: exerciseRepository) } } } diff --git a/dg-muscle-ios/sources/Domain/Rapid/Usecase/CheckAddableExerciseUsecase.swift b/dg-muscle-ios/sources/Domain/Rapid/Usecase/CheckAddableExerciseUsecase.swift new file mode 100644 index 00000000..32077147 --- /dev/null +++ b/dg-muscle-ios/sources/Domain/Rapid/Usecase/CheckAddableExerciseUsecase.swift @@ -0,0 +1,34 @@ +// +// CheckAddableExerciseUsecase.swift +// Domain +// +// Created by Happymoonday on 8/13/24. +// + +import Foundation + +public final class CheckAddableExerciseUsecase { + + public init() { } + + public func implement(exercise: RapidExerciseDomain) -> Bool { + var result: Bool = false + + switch exercise.bodyPart { + + case .back, + .chest, + .lowerArms, + .lowerLegs, + .shoulders, + .upperArms, + .waist, + .upperLegs: + result = true + case .cardio, .neck: + break + } + + return result + } +} diff --git a/dg-muscle-ios/sources/Domain/Rapid/Usecase/RegisterRapidExerciseUsecase.swift b/dg-muscle-ios/sources/Domain/Rapid/Usecase/RegisterRapidExerciseUsecase.swift new file mode 100644 index 00000000..1e211200 --- /dev/null +++ b/dg-muscle-ios/sources/Domain/Rapid/Usecase/RegisterRapidExerciseUsecase.swift @@ -0,0 +1,51 @@ +// +// RegisterRapidExerciseUsecase.swift +// Domain +// +// Created by Happymoonday on 8/13/24. +// + +import Foundation + +public final class RegisterRapidExerciseUsecase { + private let exerciseRepository: ExerciseRepository + + public init(exerciseRepository: ExerciseRepository) { + self.exerciseRepository = exerciseRepository + } + + public func implement(exercise: RapidExerciseDomain) async throws { + var exerciseDomain: Exercise? + + var parts: [Exercise.Part] = [] + + switch exercise.bodyPart { + case .back: + parts.append(.back) + case .chest: + parts.append(.chest) + case .lowerArms, .upperArms: + parts.append(.arm) + case .lowerLegs, .upperLegs: + parts.append(.leg) + case .shoulders: + parts.append(.shoulder) + case .waist: + parts.append(.core) + case .cardio, .neck: + break + } + + guard parts.isEmpty == false else { return } + + exerciseDomain = .init( + id: exercise.name.filter({ $0.isLetter }), + name: exercise.name, + parts: parts, + favorite: true + ) + + guard let exerciseDomain else { return } + try await exerciseRepository.post(exerciseDomain) + } +} diff --git a/dg-muscle-ios/sources/Presentation/History/View/Form/Main/SelectExerciseViewModel.swift b/dg-muscle-ios/sources/Presentation/History/View/Form/Main/SelectExerciseViewModel.swift index 71fbb1a7..57aa1818 100644 --- a/dg-muscle-ios/sources/Presentation/History/View/Form/Main/SelectExerciseViewModel.swift +++ b/dg-muscle-ios/sources/Presentation/History/View/Form/Main/SelectExerciseViewModel.swift @@ -68,6 +68,9 @@ final class SelectExerciseViewModel: ObservableObject { let sections = $0.map({ part, exercises in ExerciseSection(part: .init(domain: part), exercises: exercises.map({ .init(domain: $0) }))}) return self?.configureExercisePopularity(sections: sections) }) + .map({ (sections: [ExerciseSection]) -> [ExerciseSection] in + sections.sorted(by: { $0.part.rawValue < $1.part.rawValue }) + }) .assign(to: &$exericeSections) } diff --git a/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailView.swift b/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailView.swift index e23162c3..8b7da630 100644 --- a/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailView.swift +++ b/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailView.swift @@ -10,86 +10,125 @@ import Domain import MockData import Kingfisher import Flow +import Common public struct RapidExerciseDetailView: View { - let data: RapidExercisePresentation - @State private var showsSecondaryMuscles: Bool = false + @StateObject var viewModel: RapidExerciseDetailViewModel - public init(exercise: Domain.RapidExerciseDomain) { - data = .init(domain: exercise) + public init( + exercise: Domain.RapidExerciseDomain, + exerciseRepository: ExerciseRepository + ) { + _viewModel = .init(wrappedValue: .init( + exercise: exercise, + exerciseRepository: exerciseRepository + )) } public var body: some View { ScrollView { - - KFAnimatedImage(.init(string: data.gifUrl)) - + KFAnimatedImage(.init(string: viewModel.data.gifUrl)) VStack(alignment: .leading) { - Text(data.equipment.capitalized) + Text(viewModel.data.equipment.capitalized) .fontWeight(.black) - Divider() - - HStack { - Text("Body Part:") - .foregroundStyle(Color(uiColor: .secondaryLabel)) - .italic() - Text("\(data.bodyPart.rawValue.capitalized)(\(data.target.capitalized))") - } - + bodyPartsView Spacer(minLength: 8) - - Section { - if showsSecondaryMuscles { - HFlow { - ForEach(data.secondaryMuscles, id: \.self) { secondaryMuscle in - Text(secondaryMuscle) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) - ) - } - } - } - } header: { - Button { - showsSecondaryMuscles.toggle() - } label: { - HStack { - Text("secondary muscles".capitalized) - } - } - } - + secondayMusclesView Spacer(minLength: 12) - - Text("Instructions") .font(.title) .padding(.bottom, 8) - - - ForEach(Array(zip(data.instructions.indices, data.instructions)), id: \.0) { (index, instruction) in - HStack(alignment: .top) { - Text("\(index + 1). ") - Text(instruction) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.bottom, 8) - } - - + instructionsView } .padding(.horizontal) Spacer(minLength: 60) } - .animation(.default, value: showsSecondaryMuscles) - .navigationTitle(data.name.capitalized) + .animation(.default, value: viewModel.showsSecondaryMuscles) + .navigationTitle(viewModel.data.name.capitalized) .scrollIndicators(.hidden) + .overlay { + ZStack { + if viewModel.loading { + ProgressView() + } + + if viewModel.showsAddButton { + VStack { + Spacer() + + HStack { + Spacer() + Button { + viewModel.add() + } label: { + Image(systemName: "pencil.tip.crop.circle.badge.plus") + .padding() + .background { + Circle() + .fill(.thickMaterial) + .shadow(radius: 10) + } + } + .buttonStyle(.plain) + .padding() + } + } + } + + if viewModel.snackbarMessage != nil { + Common.SnackbarView(message: $viewModel.snackbarMessage) + } + } + } + } + + var bodyPartsView: some View { + HStack { + Text("Body Part:") + .foregroundStyle(Color(uiColor: .secondaryLabel)) + .italic() + Text("\(viewModel.data.bodyPart.rawValue.capitalized)(\(viewModel.data.target.capitalized))") + } + } + + var secondayMusclesView: some View { + Section { + if viewModel.showsSecondaryMuscles { + HFlow { + ForEach(viewModel.data.secondaryMuscles, id: \.self) { secondaryMuscle in + Text(secondaryMuscle) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + } + } + } + } header: { + Button { + viewModel.showsSecondaryMuscles.toggle() + } label: { + HStack { + Text("secondary muscles".capitalized) + } + } + } + } + + var instructionsView: some View { + ForEach(Array(zip(viewModel.data.instructions.indices, viewModel.data.instructions)), id: \.0) { (index, instruction) in + HStack(alignment: .top) { + Text("\(index + 1). ") + Text(instruction) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 8) + } } } @@ -98,7 +137,10 @@ public struct RapidExerciseDetailView: View { let repository = RapidRepositoryMock() return NavigationStack { - RapidExerciseDetailView(exercise: repository.get()[0]) + RapidExerciseDetailView( + exercise: repository.get()[0], + exerciseRepository: ExerciseRepositoryMock() + ) .preferredColorScheme(.dark) } } diff --git a/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailViewModel.swift b/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailViewModel.swift new file mode 100644 index 00000000..c8db74ac --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/Rapid/View/SearchDetail/RapidExerciseDetailViewModel.swift @@ -0,0 +1,48 @@ +// +// RapidExerciseDetailViewModel.swift +// Weight +// +// Created by Happymoonday on 8/13/24. +// + +import Foundation +import Combine +import Domain + +final class RapidExerciseDetailViewModel: ObservableObject { + let data: RapidExercisePresentation + @Published var showsSecondaryMuscles: Bool = false + @Published var showsAddButton: Bool = false + @Published var snackbarMessage: String? + @Published var loading: Bool = false + + private let checkAddableExerciseUsecase: CheckAddableExerciseUsecase + private let registerRapidExerciseUsecase: RegisterRapidExerciseUsecase + + init( + exercise: Domain.RapidExerciseDomain, + exerciseRepository: ExerciseRepository + ) { + data = .init(domain: exercise) + + checkAddableExerciseUsecase = .init() + registerRapidExerciseUsecase = .init(exerciseRepository: exerciseRepository) + + showsAddButton = checkAddableExerciseUsecase.implement(exercise: data.domain) + } + + @MainActor + func add() { + Task { + guard loading == false else { return } + loading = true + do { + try await registerRapidExerciseUsecase.implement(exercise: data.domain) + snackbarMessage = "Exercise Registered!" + } catch { + snackbarMessage = error.localizedDescription + } + loading = false + } + } +}