diff --git a/Application/Project.swift b/Application/Project.swift index 86fa0e72..633d3942 100644 --- a/Application/Project.swift +++ b/Application/Project.swift @@ -32,6 +32,7 @@ let project = Project( .Service.UserService, .SPM.FirebaseMessaging, .SPM.SemicolonDesign, + .SPM.ComposableArchitecture, .target(name: "XquareWidget") ] + TargetDependency.universalDependencies, settings: .settings(base: ["OTHER_LDFLAGS": "-ObjC"]) diff --git a/Application/Sources/Application/AppDependency.swift b/Application/Sources/Application/AppDependency.swift index aff95ea5..7a1b25de 100644 --- a/Application/Sources/Application/AppDependency.swift +++ b/Application/Sources/Application/AppDependency.swift @@ -3,9 +3,11 @@ import Foundation import AuthService import MealDataService import UserService +import ComposableArchitecture struct AppDependency { let launchScreenView: LaunchScreenView + let signupView: SignupView } // swiftlint:disable function_body_length @@ -28,8 +30,10 @@ extension AppDependency { let loginViewModel = LoginViewModel( signInUseCase: authServiceDependency.signinUseCase ) - let signupViewModel = SignupViewModel( - signupUseCase: authServiceDependency.signupUseCase + let signupStore = Store( + initialState: SignupState(), + reducer: SignupReducer.reducer, + environment: SignupEnvironment(signupUseCase: authServiceDependency.signupUseCase) ) let launchScreenViewModel = LaunchScreenViewModel( refreshTokenUseCase: authServiceDependency.refreshTokenUseCase @@ -61,7 +65,7 @@ extension AppDependency { mainView: mainView ) let signupView = SignupView( - viewModel: signupViewModel, + store: signupStore, loginView: loginView ) let onboardingView = OnboardingView( @@ -75,7 +79,8 @@ extension AppDependency { ) return AppDependency( - launchScreenView: launchScreenView + launchScreenView: launchScreenView, + signupView: signupView ) } diff --git a/Application/Sources/Scene/Signup/SignupAction.swift b/Application/Sources/Scene/Signup/SignupAction.swift new file mode 100644 index 00000000..98290d4e --- /dev/null +++ b/Application/Sources/Scene/Signup/SignupAction.swift @@ -0,0 +1,16 @@ +import Foundation + +import AuthService +import ComposableArchitecture + +enum SignupAction { + case signup + case signupIsSuccess(Result) + case checkPasswordIsEqual + case checkButtonDisabled + case binding(BindingAction) + case authCodeChanged(String) + case idChanged(String) + case passwordChanged(String) + case reEnterPasswordChange(String) +} diff --git a/Application/Sources/Scene/Signup/SignupEnvironment.swift b/Application/Sources/Scene/Signup/SignupEnvironment.swift new file mode 100644 index 00000000..8407e8f6 --- /dev/null +++ b/Application/Sources/Scene/Signup/SignupEnvironment.swift @@ -0,0 +1,7 @@ +import Foundation + +import AuthService + +struct SignupEnvironment { + let signupUseCase: SignupUseCase +} diff --git a/Application/Sources/Scene/Signup/SignupReducer.swift b/Application/Sources/Scene/Signup/SignupReducer.swift new file mode 100644 index 00000000..1daaee39 --- /dev/null +++ b/Application/Sources/Scene/Signup/SignupReducer.swift @@ -0,0 +1,79 @@ +import Foundation + +import AuthService +import ComposableArchitecture + +struct SignupReducer { + + static let reducer = AnyReducer { state, action, environemnt in + switch action { + case .authCodeChanged(let authCode): + state.authCode = authCode + return .none + case .idChanged(let id): + state.id = id + return .none + case .passwordChanged(let password): + state.password = password + return .none + case .reEnterPasswordChange(let password): + state.reEnterPassword = password + return .none + case .signup: + return environemnt.signupUseCase.excute(data: .init( + authCode: state.authCode, + id: state.id, + profileImageUrl: nil, + password: state.password + )) + .catchToEffect(SignupAction.signupIsSuccess) + .eraseToEffect() + case .signupIsSuccess(.success(let success)): + state.isSuccess = true + return .none + case .signupIsSuccess(.failure(let error)): + if error == .duplicateId { + state.isSuccess = false + state.idErrorMessage = "아이디가 중복되었습니다." + } else if error == .networkNotWorking { + state.isInternetNotworking = true + state.isSuccess = false + } + return .none + case .checkPasswordIsEqual: + state.passwordErrorMessage = isPasswordEqual( + state.password, + state.reEnterPassword + ) ? "" : "비밀번호가 일치하지 않습니다." + + return .none + case .checkButtonDisabled: + state.isDisable = !isPasswordEqual( + state.password, + state.reEnterPassword + ) || !checkId(state.id) || !checkAuthCode(state.authCode) || !checkPassword(state.password) + return .none + case .binding: + return .none + } + } + + private static func isPasswordEqual(_ password: String, _ reEnterPassword: String) -> Bool { + return password == reEnterPassword + } + private static func checkId(_ id: String) -> Bool { + let strRegEx = "[A-Za-z0-9]{6,20}" + let pred = NSPredicate(format: "SELF MATCHES %@", strRegEx) + + return pred.evaluate(with: id) + } + private static func checkPassword(_ password: String) -> Bool { + let strRegEx = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[!@#$%^&*()_+=-]).{6,30}" + let pred = NSPredicate(format: "SELF MATCHES %@", strRegEx) + + return pred.evaluate(with: password) + } + private static func checkAuthCode(_ authCode: String) -> Bool { + return authCode.count == 6 + } +} diff --git a/Application/Sources/Scene/Signup/SignupState.swift b/Application/Sources/Scene/Signup/SignupState.swift new file mode 100644 index 00000000..762e5894 --- /dev/null +++ b/Application/Sources/Scene/Signup/SignupState.swift @@ -0,0 +1,13 @@ +import Foundation + +struct SignupState: Equatable { + var authCode: String = "" + var id: String = "" + var password: String = "" + var reEnterPassword: String = "" + var idErrorMessage: String = "" + var passwordErrorMessage: String = "" + var isSuccess: Bool = false + var isDisable: Bool = true + var isInternetNotworking: Bool = false +} diff --git a/Application/Sources/Scene/Signup/SignupView.swift b/Application/Sources/Scene/Signup/SignupView.swift index 9dc597fd..9514e004 100644 --- a/Application/Sources/Scene/Signup/SignupView.swift +++ b/Application/Sources/Scene/Signup/SignupView.swift @@ -2,86 +2,108 @@ import SwiftUI import SemicolonDesign import AuthService +import ComposableArchitecture struct SignupView: View { - @StateObject var viewModel: SignupViewModel + let store: Store var loginView: LoginView var body: some View { - NavigationView { - ZStack { - ScrollView { - VStack(spacing: 20) { - Spacer() - .frame(height: 16) - SDTextField( - title: "인증코드", - placeholder: "6자리를 입력해주세요", - text: $viewModel.authCode - ) - .padding(.horizontal, 16) - .onChange(of: viewModel.authCode) { _ in - viewModel.checkSignup() - } - SDTextField( - title: "아이디", - placeholder: "영문, 숫자 6~20자", - text: $viewModel.id, - errorMessage: viewModel.idErrorMessage - ) - .padding(.horizontal, 16) - .onChange(of: viewModel.id) { _ in - viewModel.checkSignup() - } - SDTextField( - title: "비밀번호", - placeholder: "숫자, 영문, 특수문자 조합 최소 6자", - text: $viewModel.password, - isSecure: true - ) - .padding(.horizontal, 16) - .onChange(of: viewModel.password) { _ in - viewModel.equalPasswordError() - viewModel.checkSignup() + WithViewStore(self.store) { viewStore in + NavigationView { + ZStack { + ScrollView { + VStack(spacing: 20) { + Spacer() + .frame(height: 16) + SDTextField( + title: "인증코드", + placeholder: "6자리를 입력해주세요", + text: viewStore.binding( + get: \.authCode, + send: SignupAction.authCodeChanged + ) + ) + .padding(.horizontal, 16) + .onChange(of: viewStore.state.authCode) { _ in + viewStore.send(.checkButtonDisabled) + } + SDTextField( + title: "아이디", + placeholder: "영문, 숫자 6~20자", + text: viewStore.binding( + get: \.id, + send: SignupAction.idChanged + ), + errorMessage: viewStore.state.idErrorMessage + ) + .padding(.horizontal, 16) + .onChange(of: viewStore.state.id) { _ in + viewStore.send(.checkButtonDisabled) + } + SDTextField( + title: "비밀번호", + placeholder: "숫자, 영문, 특수문자 조합 최소 6자", + text: viewStore.binding( + get: \.password, + send: SignupAction.passwordChanged + ), + isSecure: true + ) + .padding(.horizontal, 16) + .onChange(of: viewStore.state.password) { _ in + viewStore.send(.checkPasswordIsEqual) + viewStore.send(.checkButtonDisabled) + } + SDTextField( + title: "비밀번호 재입력", + placeholder: "재입력", + text: viewStore.binding( + get: \.reEnterPassword, + send: SignupAction.reEnterPasswordChange + ), + errorMessage: viewStore.state.passwordErrorMessage, + isSecure: true + ) + .padding(.horizontal, 16) + .onChange(of: viewStore.state.reEnterPassword) { _ in + viewStore.send(.checkPasswordIsEqual) + viewStore.send(.checkButtonDisabled) + } + TermsCaptionView() + Spacer().frame(height: 64) } - SDTextField( - title: "비밀번호 재입력", - placeholder: "재입력", - text: $viewModel.reEnterPassword, - errorMessage: viewModel.passwordErrorMessage, - isSecure: true + } + VStack { + Spacer() + FillButton( + isDisabled: viewStore.binding( + get: \.isDisable, + send: SignupAction.checkButtonDisabled + ), + text: "입력 완료", + action: { viewStore.send(.signup) }, + type: .rounded ) - .padding(.horizontal, 16) - .onChange(of: viewModel.reEnterPassword) { _ in - viewModel.equalPasswordError() - viewModel.checkSignup() + .fullScreenCover(isPresented: viewStore.binding( + get: \.isSuccess, + send: SignupAction.signup + )) { + loginView } - TermsCaptionView() - Spacer().frame(height: 64) - } - } - VStack { - Spacer() - FillButton( - isDisabled: $viewModel.isDisabled, - text: "입력 완료", - action: { - viewModel.signup() - }, - type: .rounded - ) - .fullScreenCover(isPresented: $viewModel.isSuccess) { - loginView } } + .sdErrorAlert(isPresented: viewStore.binding( + get: \.isInternetNotworking, + send: SignupAction.signup + ), sdAlert: { + SDErrorAlert(errerMessage: "네트워크가 원할하지 않습니다.") + }) + .navigationTitle("회원가입") + .setNavigationBackButton() } - .sdErrorAlert(isPresented: $viewModel.isInternetNotWorking, sdAlert: { - SDErrorAlert(errerMessage: "네트워크가 원할하지 않습니다.") - }) - .navigationTitle("회원가입") - .setNavigationBackButton() + .accentColor(.GrayScale.gray800) } - .accentColor(.GrayScale.gray800) } } diff --git a/Application/Sources/Scene/Signup/SignupViewModel.swift b/Application/Sources/Scene/Signup/SignupViewModel.swift deleted file mode 100644 index 91eba823..00000000 --- a/Application/Sources/Scene/Signup/SignupViewModel.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation - -import AuthService -import RxSwift - -class SignupViewModel: ObservableObject { - @Published var authCode: String = "" - @Published var id: String = "" - @Published var password: String = "" - @Published var reEnterPassword: String = "" - @Published var passwordErrorMessage: String = "" - @Published var idErrorMessage: String = "" - @Published var isSuccess: Bool = false - @Published var isDisabled: Bool = true - @Published var isInternetNotWorking: Bool = false - - private let signupuseCase: SignupUseCase - - private var disposeBag = DisposeBag() - - init(signupUseCase: SignupUseCase) { - self.signupuseCase = signupUseCase - } - - func signup() { - self.signupuseCase.excute(data: .init( - authCode: authCode, - id: id, - profileImageUrl: nil, - password: password - )) - .subscribe(onCompleted: { [weak self] in - self?.isInternetNotWorking = false - self?.isSuccess = true - }, onError: { [weak self] in - if $0.asAuthServiceError == .duplicateId { - self?.idErrorMessage = "아이디가 중복되었습니다." - } else if $0.asAuthServiceError == .networkNotWorking { - self?.isInternetNotWorking = true - } - self?.isSuccess = false - }) - .disposed(by: self.disposeBag) - } - - func checkSignup() { - self.isDisabled = !isCheckAuthCode() || !isIdCheck() || !isPasswordCheck() || !isReEnterPasswordCheck() - } - - func equalPasswordError() { - if !isReEnterPasswordCheck() { - self.passwordErrorMessage = "비밀번호가 일치하지 않습니다." - } else { - self.passwordErrorMessage = "" - } - } - - private func isCheckAuthCode() -> Bool { - return authCode.count == 6 - } - - private func isIdCheck() -> Bool { - let strRegEx = "[A-Za-z0-9]{6,20}" - let pred = NSPredicate(format: "SELF MATCHES %@", strRegEx) - - return pred.evaluate(with: self.id) - } - - private func isPasswordCheck() -> Bool { - let strRegEx = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[!@#$%^&*()_+=-]).{6,30}" - let pred = NSPredicate(format: "SELF MATCHES %@", strRegEx) - - return pred.evaluate(with: self.password) - } - - private func isReEnterPasswordCheck() -> Bool { - return self.password == self.reEnterPassword - } -} diff --git a/Services/AuthService/Sources/Domain/UseCase/SignupUseCase.swift b/Services/AuthService/Sources/Domain/UseCase/SignupUseCase.swift index c235d922..8fde1d40 100644 --- a/Services/AuthService/Sources/Domain/UseCase/SignupUseCase.swift +++ b/Services/AuthService/Sources/Domain/UseCase/SignupUseCase.swift @@ -1,17 +1,28 @@ import Foundation import RxSwift +import Combine +import ComposableArchitecture public class SignupUseCase { private let authRepository: AuthRepository + private var disposeBag = DisposeBag() init(authRepository: AuthRepository) { self.authRepository = authRepository } - public func excute(data: SignupEntity) -> Completable { - self.authRepository.signup(signupEntity: data) + public func excute(data: SignupEntity) -> Future { + return Future { promise in + self.authRepository.signup(signupEntity: data) + .subscribe(onCompleted: { + promise(.success(())) + }, onError: { error in + promise(.failure(error as? AuthServiceError ?? .networkNotWorking)) + }) + .disposed(by: self.disposeBag) + } } } diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift index 5b030182..2caa6aa8 100644 --- a/Tuist/Dependencies.swift +++ b/Tuist/Dependencies.swift @@ -25,7 +25,10 @@ let dependencies = Dependencies( requirement: .upToNextMajor(from: "0.13.3")), // SemicolonDesign .remote(url: "https://github.com/semicolonDSM/SemicolonDesign_iOS.git", - requirement: .upToNextMajor(from: "1.8.1")) + requirement: .upToNextMajor(from: "1.8.1")), + // TCA + .remote(url: "https://github.com/pointfreeco/swift-composable-architecture", + requirement: .upToNextMajor(from: "0.47.2")) ], baseSettings: Settings.settings( configurations: [ diff --git a/Tuist/ProjectDescriptionHelpers/TargetDependency/SPM.swift b/Tuist/ProjectDescriptionHelpers/TargetDependency/SPM.swift index 399230b4..e8f83caf 100644 --- a/Tuist/ProjectDescriptionHelpers/TargetDependency/SPM.swift +++ b/Tuist/ProjectDescriptionHelpers/TargetDependency/SPM.swift @@ -23,6 +23,7 @@ extension TargetDependency { public static let SQLite = TargetDependency.external(name: "SQLite") public static let SemicolonDesign = TargetDependency.external(name: "SemicolonDesign") + public static let ComposableArchitecture = TargetDependency.external(name: "ComposableArchitecture") }