diff --git a/project/Projects/Domain/DomainTests/RegisterValidationTests.swift b/project/Projects/Domain/DomainTests/RegisterValidationTests.swift index f6842ff9..2cfaee19 100644 --- a/project/Projects/Domain/DomainTests/RegisterValidationTests.swift +++ b/project/Projects/Domain/DomainTests/RegisterValidationTests.swift @@ -71,11 +71,11 @@ final class RegisterValidationTests: XCTestCase { let usecase = DefaultAuthInputValidationUseCase() // 유효한 비밀번호 테스트 - XCTAssertTrue(usecase.checkPasswordIsValid(password: "Password1")) - XCTAssertTrue(usecase.checkPasswordIsValid(password: "pass1234")) - XCTAssertTrue(usecase.checkPasswordIsValid(password: "1234Abcd!")) - XCTAssertTrue(usecase.checkPasswordIsValid(password: "Valid123")) - XCTAssertTrue(usecase.checkPasswordIsValid(password: "StrongPass1!")) + XCTAssertTrue(usecase.checkPasswordIsValid(password: "Password1").isPasswordValid) + XCTAssertTrue(usecase.checkPasswordIsValid(password: "pass1234").isPasswordValid) + XCTAssertTrue(usecase.checkPasswordIsValid(password: "1234Abcd!").isPasswordValid) + XCTAssertTrue(usecase.checkPasswordIsValid(password: "Valid123").isPasswordValid) + XCTAssertTrue(usecase.checkPasswordIsValid(password: "StrongPass1!").isPasswordValid) } func testInvalidPassword() { @@ -83,10 +83,10 @@ final class RegisterValidationTests: XCTestCase { let usecase = DefaultAuthInputValidationUseCase() // 유효하지 않은 비밀번호 테스트 - XCTAssertFalse(usecase.checkPasswordIsValid(password: "short1")) // 너무 짧음 - XCTAssertFalse(usecase.checkPasswordIsValid(password: "alllowercase")) // 숫자 없음 - XCTAssertFalse(usecase.checkPasswordIsValid(password: "ALLUPPERCASE")) // 숫자 없음 - XCTAssertFalse(usecase.checkPasswordIsValid(password: "12345678")) // 영문자 없음 - XCTAssertFalse(usecase.checkPasswordIsValid(password: "123456789012345678901")) // 너무 길음 + XCTAssertFalse(usecase.checkPasswordIsValid(password: "short1").isPasswordValid) // 너무 짧음 + XCTAssertFalse(usecase.checkPasswordIsValid(password: "alllowercase").isPasswordValid) // 숫자 없음 + XCTAssertFalse(usecase.checkPasswordIsValid(password: "ALLUPPERCASE").isPasswordValid) // 숫자 없음 + XCTAssertFalse(usecase.checkPasswordIsValid(password: "12345678").isPasswordValid) // 영문자 없음 + XCTAssertFalse(usecase.checkPasswordIsValid(password: "123456789012345678901").isValid) // 너무 길음 } } diff --git a/project/Projects/Domain/Sources/ConcreteUseCase/Auth/DefaultAuthInputValidationUseCase.swift b/project/Projects/Domain/Sources/ConcreteUseCase/Auth/DefaultAuthInputValidationUseCase.swift index 90033d75..8ec85610 100644 --- a/project/Projects/Domain/Sources/ConcreteUseCase/Auth/DefaultAuthInputValidationUseCase.swift +++ b/project/Projects/Domain/Sources/ConcreteUseCase/Auth/DefaultAuthInputValidationUseCase.swift @@ -67,10 +67,36 @@ public class DefaultAuthInputValidationUseCase: AuthInputValidationUseCase { .requestCheckingIdDuplication(id: id) } - public func checkPasswordIsValid(password: String) -> Bool { - let passwordLengthAndCharRegex = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d!@#$%^&*()_+=-]{8,20}$" - let predicate = NSPredicate(format: "SELF MATCHES %@", passwordLengthAndCharRegex) + public func checkPasswordIsValid(password: String) -> PasswordValidationState { - return predicate.evaluate(with: password) + // 1. 8자 ~ 20자 사이 + let lengthRegex = "^.{8,20}$" + let lengthIsValid = evaluateStringWith(regex: lengthRegex, targetString: password) + + // 2. 영문자와 숫자 반드시 하나씩 포함 + let letterAndNumberRegex = "^(?=.*[A-Za-z])(?=.*[0-9]).*$" + let letterAndNumberIsValid = evaluateStringWith(regex: letterAndNumberRegex, targetString: password) + + // 3. 공백 문자 사용 금지 + let noWhitespaceRegex = "^\\S*$" + let noWhitespaceIsValid = evaluateStringWith(regex: noWhitespaceRegex, targetString: password) + + // 4. 연속된 문자 3개 이상 사용 금지 + let noTripleRepeatedCharsRegex = "^(?!.*(.)\\1{2,}).*$" + let noTripleRepeatedCharsIsValid = evaluateStringWith(regex: noTripleRepeatedCharsRegex, targetString: password) + + return PasswordValidationState( + characterCount: lengthIsValid ? .valid : .invalid, + alphabetAndNumberIncluded: letterAndNumberIsValid ? .valid : .invalid, + noEmptySpace: noWhitespaceIsValid ? .valid : .invalid, + unsuccessiveSame3words: noTripleRepeatedCharsIsValid ? .valid : .invalid + ) + } + + private func evaluateStringWith(regex: String, targetString: String) -> Bool { + + let predicate = NSPredicate(format: "SELF MATCHES %@", regex) + + return predicate.evaluate(with: targetString) } } diff --git a/project/Projects/Domain/Sources/Entity/State/Auth/Center/PasswordValidationState.swift b/project/Projects/Domain/Sources/Entity/State/Auth/Center/PasswordValidationState.swift index 4107401a..0c13f984 100644 --- a/project/Projects/Domain/Sources/Entity/State/Auth/Center/PasswordValidationState.swift +++ b/project/Projects/Domain/Sources/Entity/State/Auth/Center/PasswordValidationState.swift @@ -1,15 +1,90 @@ // // PasswordValidationState.swift -// ConcreteUseCase +// Domain // -// Created by choijunios on 7/7/24. +// Created by choijunios on 10/22/24. // import Foundation -public enum PasswordValidationState { +public class PasswordValidationState { - case invalidPassword - case unMatch - case match + public enum State { + case valid + case invalid + } + + public let characterCount: State + public let alphabetAndNumberIncluded: State + public let noEmptySpace: State + public let unsuccessiveSame3words: State + public private(set) var isEditingAndCheckingPasswordsEqual: Bool = false + + public init( + characterCount: State, + alphabetAndNumberIncluded: State, + noEmptySpace: State, + unsuccessiveSame3words: State + ) { + self.characterCount = characterCount + self.alphabetAndNumberIncluded = alphabetAndNumberIncluded + self.noEmptySpace = noEmptySpace + self.unsuccessiveSame3words = unsuccessiveSame3words + } + + public var isValid: Bool { + + return ( + isPasswordValid + && + isEditingAndCheckingPasswordsEqual + ) + } + + public var isPasswordValid: Bool { + characterCount == .valid + && + alphabetAndNumberIncluded == .valid + && + noEmptySpace == .valid + && + unsuccessiveSame3words == .valid + } + + public func setEqualState(state: Bool) { + isEditingAndCheckingPasswordsEqual = state + } +} + +public extension PasswordValidationState { + + var description: String { + var descriptions: [String] = [] + + if characterCount == .valid { + descriptions.append("비밀번호 길이: 유효함 (8자 이상 20자 이하)") + } else { + descriptions.append("비밀번호 길이: 유효하지 않음 (8자 이상 20자 이하이어야 함)") + } + + if alphabetAndNumberIncluded == .valid { + descriptions.append("영문자와 숫자: 유효함 (영문자와 숫자가 모두 포함됨)") + } else { + descriptions.append("영문자와 숫자: 유효하지 않음 (영문자와 숫자가 반드시 포함되어야 함)") + } + + if noEmptySpace == .valid { + descriptions.append("공백 문자: 없음 (공백 문자를 사용할 수 없음)") + } else { + descriptions.append("공백 문자: 유효하지 않음 (공백 문자가 포함되어 있음)") + } + + if unsuccessiveSame3words == .valid { + descriptions.append("연속된 문자 3개 이상 사용: 유효함 (연속된 동일 문자가 없음)") + } else { + descriptions.append("연속된 문자 3개 이상 사용: 유효하지 않음 (연속된 동일 문자가 3개 이상 포함됨)") + } + + return descriptions.joined(separator: "\n") + } } diff --git a/project/Projects/Domain/Sources/Entity/VO/UserInfo/CenterJoinStatusInfoVO.swift b/project/Projects/Domain/Sources/Entity/VO/UserInfo/CenterJoinStatusInfoVO.swift index 24eace65..202a270e 100644 --- a/project/Projects/Domain/Sources/Entity/VO/UserInfo/CenterJoinStatusInfoVO.swift +++ b/project/Projects/Domain/Sources/Entity/VO/UserInfo/CenterJoinStatusInfoVO.swift @@ -12,6 +12,18 @@ public struct CenterJoinStatusInfoVO: Codable { public let managerName: String public let phoneNumber: String public let centerManagerAccountStatus: CenterAccountStatus + + public init( + id: String, + managerName: String, + phoneNumber: String, + centerManagerAccountStatus: CenterAccountStatus + ) { + self.id = id + self.managerName = managerName + self.phoneNumber = phoneNumber + self.centerManagerAccountStatus = centerManagerAccountStatus + } } public enum CenterAccountStatus: String, Codable { diff --git a/project/Projects/Domain/Sources/UseCaseInterface/Auth/AuthInputValidationUseCase.swift b/project/Projects/Domain/Sources/UseCaseInterface/Auth/AuthInputValidationUseCase.swift index 3c8bcd47..8e2bda65 100644 --- a/project/Projects/Domain/Sources/UseCaseInterface/Auth/AuthInputValidationUseCase.swift +++ b/project/Projects/Domain/Sources/UseCaseInterface/Auth/AuthInputValidationUseCase.swift @@ -86,8 +86,8 @@ public protocol AuthInputValidationUseCase: BaseUseCase { /// - parameters: /// - password : "password1234" /// - returns: - /// - Bool, true: 가능, flase: 불가능 - func checkPasswordIsValid(password: String) -> Bool + /// - PasswordValidationState + func checkPasswordIsValid(password: String) -> PasswordValidationState // #9. /// 이름 유효성 확인 로직 diff --git a/project/Projects/Presentation/DSKit/Resources/Colors.xcassets/green.colorset/Contents.json b/project/Projects/Presentation/DSKit/Resources/Colors.xcassets/green.colorset/Contents.json new file mode 100644 index 00000000..f1e2fd65 --- /dev/null +++ b/project/Projects/Presentation/DSKit/Resources/Colors.xcassets/green.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4D", + "green" : "0xC3", + "red" : "0x2C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4D", + "green" : "0xC3", + "red" : "0x2C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/project/Projects/Presentation/Feature/Auth/ExampleApp/Sources/SceneDelegate.swift b/project/Projects/Presentation/Feature/Auth/ExampleApp/Sources/SceneDelegate.swift index 583c671d..c109150c 100644 --- a/project/Projects/Presentation/Feature/Auth/ExampleApp/Sources/SceneDelegate.swift +++ b/project/Projects/Presentation/Feature/Auth/ExampleApp/Sources/SceneDelegate.swift @@ -7,18 +7,68 @@ import UIKit +import AuthFeature +import BaseFeature +import Testing +import Core + class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + var authCoordinator: AuthCoordinator? + var centerAccountRegisterCoordinator: CenterAccountRegisterCoordinator? + var centerLogInCoordinator: CenterLogInCoordinator? + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } - - window = UIWindow(windowScene: windowScene) + DependencyInjector.shared.assemble(MockAssemblies) + DependencyInjector.shared.register(CenterRegisterLogger.self, CenterAuthLogger()) + authCoordinator = .init() + authCoordinator?.startFlow = { [weak self] desination in + + switch desination { + case .centerRegisterPage: + + let coordinator = CenterAccountRegisterCoordinator() + self?.centerAccountRegisterCoordinator = coordinator + coordinator.start() + + case .loginPage: + let coordinator = CenterLogInCoordinator() + + coordinator.startFlow = { desination in + switch desination { + default: + // 센터 메인페이지로 이동 + return + } + } + + self?.centerLogInCoordinator = coordinator + coordinator.start() + default: + // 테스트시 추가가능 + return + } + } + + window = UIWindow(windowScene: windowScene) window?.makeKeyAndVisible() + + authCoordinator?.start() } } + +class CenterAuthLogger: CenterRegisterLogger { + + func logCenterRegisterStep(stepName: String, stepIndex: Int) { } + + func startCenterRegister() { } + + func logCenterRegisterDuration() { } +} diff --git a/project/Projects/Presentation/Feature/Auth/Project.swift b/project/Projects/Presentation/Feature/Auth/Project.swift index 82be4acb..646ccde1 100644 --- a/project/Projects/Presentation/Feature/Auth/Project.swift +++ b/project/Projects/Presentation/Feature/Auth/Project.swift @@ -46,6 +46,7 @@ let project = Project( resources: ["ExampleApp/Resources/**"], dependencies: [ .target(name: "AuthFeature"), + D.Testing, ], settings: .settings( configurations: IdleConfiguration.presentationConfigurations diff --git a/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/Contents.json b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_f_mark.imageset/Contents.json b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_f_mark.imageset/Contents.json new file mode 100644 index 00000000..05348944 --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_f_mark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "v_f_mark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_f_mark.imageset/v_f_mark.svg b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_f_mark.imageset/v_f_mark.svg new file mode 100644 index 00000000..a16a660d --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_f_mark.imageset/v_f_mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_s_mark.imageset/Contents.json b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_s_mark.imageset/Contents.json new file mode 100644 index 00000000..15dc2b5b --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_s_mark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "v_s_mark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_s_mark.imageset/v_s_mark.svg b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_s_mark.imageset/v_s_mark.svg new file mode 100644 index 00000000..781e7cd1 --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Resources/Asset.xcassets/v_s_mark.imageset/v_s_mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterCoordinator.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterCoordinator.swift index 8c6ef06b..899a5dbf 100644 --- a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterCoordinator.swift +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterCoordinator.swift @@ -89,6 +89,11 @@ public class CenterAccountRegisterCoordinator: Coordinator { // 완료화면으로 이동 self?.router.presentAnonymousCompletePage(object) } + + vm.presentAlert = { [weak self] object in + + self?.router.presentDefaultAlertController(object: object) + } self.stageViewControllers = [ EnterNameViewController(viewModel: vm), diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterViewModel.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterViewModel.swift index acc0288b..96216dae 100644 --- a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterViewModel.swift +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/CenterAccountRegisterViewModel.swift @@ -14,19 +14,20 @@ import Core import RxSwift import RxCocoa -public class CenterAccountRegisterViewModel: BaseViewModel, ViewModelType { +class CenterAccountRegisterViewModel: BaseViewModel, ViewModelType { // Injected @Injected var inputValidationUseCase: AuthInputValidationUseCase @Injected var authUseCase: AuthUseCase - var presentNextPage: (() -> ())! - var presentPrevPage: (() -> ())! - var presentCompleteScreen: (() -> ())! + var presentNextPage: (() -> ())? + var presentPrevPage: (() -> ())? + var presentCompleteScreen: (() -> ())? + var presentAlert: ((DefaultAlertObject) -> ())? // Input은 모든 ViewController에서 공유한다. (다만, 각가의 ViewController의 Input프로토콜에 의해 제한된다.) - public let input = Input() - public let output = Output() + let input = Input() + let output = Output() internal let stateObject = CenterRegisterState() @@ -56,21 +57,10 @@ public class CenterAccountRegisterViewModel: BaseViewModel, ViewModelType { registerInOut() validateBusinessNumberInOut() - AuthInOutStreamManager.idInOut( - input: input, - output: output, - useCase: inputValidationUseCase) { [weak self] validId in - // 🚀 상태추적 🚀 - self?.stateObject.id = validId - } - AuthInOutStreamManager.passwordInOut( - input: input, - output: output, - useCase: inputValidationUseCase) { [weak self] validPassword in - // 🚀 상태추적 🚀 - self?.stateObject.password = validPassword - } + // MARK: Id & Password + idAndPasswordValidationBinding() + input.alert .subscribe(onNext: { [weak self] alertVO in @@ -83,7 +73,7 @@ public class CenterAccountRegisterViewModel: BaseViewModel, ViewModelType { .nextButtonClicked .unretained(self) .subscribe(onNext: { (obj, _) in - obj.presentNextPage() + obj.presentNextPage?() }) .disposed(by: disposeBag) @@ -91,7 +81,7 @@ public class CenterAccountRegisterViewModel: BaseViewModel, ViewModelType { .prevButtonClicked .unretained(self) .subscribe(onNext: { (obj, _) in - obj.presentPrevPage() + obj.presentPrevPage?() }) .disposed(by: disposeBag) } @@ -101,65 +91,155 @@ public class CenterAccountRegisterViewModel: BaseViewModel, ViewModelType { } } + +// MARK: Id & Password validation +extension CenterAccountRegisterViewModel { + + func idAndPasswordValidationBinding() { + + // ID + output.idValidationResult = input + .editingId + .unretained(self) + .map { (vm, id) in + vm.inputValidationUseCase.checkIdIsValid(id: id) + } + .asDriver(onErrorDriveWith: .never()) + + let idDuplicationCheckResult = input + .isIdDuplicatedButtonPressed + .withLatestFrom(input.editingId) + .unretained(self) + .flatMap { (vm, id) in + + printIfDebug("[CenterRegisterViewModel] 중복성 검사 대상 id: \(id)") + + // 검증시 가장 최근 id저장 + vm.stateObject.id = id + + #if DEBUG + // 디버그시 아이디 중복체크 미실시 + print("✅ 디버그모드에서 아이디 중복검사 미실시") + return Single.just(Result.success(())) + #endif + + return vm.inputValidationUseCase.requestCheckingIdDuplication(id: id) + } + .share() + + output.idDuplicationCheckResult = idDuplicationCheckResult + .map { result in + switch result { + case .success: + return true + case .failure: + return false + } + } + .asDriver(onErrorDriveWith: .never()) + + let idDuplicationFailure = idDuplicationCheckResult.compactMap { $0.error } + + idDuplicationFailure + .unretained(self) + .subscribe(onNext: { (vm, error) in + + let alertObject: DefaultAlertObject = .init() + alertObject.setTitle("아이디 중복검사 실패") + alertObject.setDescription(error.message) + + vm.presentAlert?(alertObject) + }) + .disposed(by: disposeBag) + + + // Passwords + output.passwordValidationState = Observable + .combineLatest( + input.editingPassword, + input.checkingPassword + ) + .unretained(self) + .map { (vm, passwords) in + + let (editing, checking) = passwords + + let stateObject: PasswordValidationState = vm.inputValidationUseCase + .checkPasswordIsValid(password: editing) + + stateObject.setEqualState(state: editing == checking) + + // 가장 최근 비밀번호 저장 + vm.stateObject.password = editing + + printIfDebug(stateObject.description) + + return stateObject + } + .asDriver(onErrorDriveWith: .never()) + } +} + // MARK: ViewModel input output extension CenterAccountRegisterViewModel { - public class Input { + class Input { // CTA 버튼 클릭시 - public var nextButtonClicked: PublishSubject = .init() - public var prevButtonClicked: PublishSubject = .init() - public var completeButtonClicked: PublishSubject = .init() + var nextButtonClicked: PublishSubject = .init() + var prevButtonClicked: PublishSubject = .init() + var completeButtonClicked: PublishSubject = .init() // 이름입력 public var editingName: PublishRelay = .init() // 전화번호 입력 - public var editingPhoneNumber: BehaviorRelay = .init(value: "") - public var editingAuthNumber: BehaviorRelay = .init(value: "") - public var requestAuthForPhoneNumber: PublishRelay = .init() - public var requestValidationForAuthNumber: PublishRelay = .init() + var editingPhoneNumber: BehaviorRelay = .init(value: "") + var editingAuthNumber: BehaviorRelay = .init(value: "") + var requestAuthForPhoneNumber: PublishRelay = .init() + var requestValidationForAuthNumber: PublishRelay = .init() // 사업자 번호 입력 - public var editingBusinessNumber: BehaviorRelay = .init(value: "") - public var requestBusinessNumberValidation: PublishRelay = .init() + var editingBusinessNumber: BehaviorRelay = .init(value: "") + var requestBusinessNumberValidation: PublishRelay = .init() // Id - public var editingId: BehaviorRelay = .init(value: "") - public var requestIdDuplicationValidation: PublishRelay = .init() + var editingId: PublishSubject = .init() + var isIdDuplicatedButtonPressed: PublishSubject = .init() // Password - public var editingPasswords: PublishRelay<(pwd: String, cpwd: String)> = .init() + var editingPassword: PublishSubject = .init() + var checkingPassword: BehaviorSubject = .init(value: "") // Alert - public var alert: PublishSubject = .init() + var alert: PublishSubject = .init() } - public class Output { + class Output { // 이름 입력 public var nameValidation: Driver? // 전화번호 입력 - public var canSubmitPhoneNumber: Driver? - public var canSubmitAuthNumber: Driver? - public var phoneNumberValidation: Driver? - public var authNumberValidation: Driver? + var canSubmitPhoneNumber: Driver? + var canSubmitAuthNumber: Driver? + var phoneNumberValidation: Driver? + var authNumberValidation: Driver? // 사업자 번호 입력 - public var canSubmitBusinessNumber: Driver? - public var businessNumberVO: Driver? - public var businessNumberValidationFailure: Driver? + var canSubmitBusinessNumber: Driver? + var businessNumberVO: Driver? + var businessNumberValidationFailure: Driver? // Id - public var canCheckIdDuplication: Driver? - public var idDuplicationValidation: Driver? + var idValidationResult: Driver = .empty() + var idDuplicationCheckResult: Driver = .empty() // Password - public var passwordValidation: Driver? + var passwordValidationState: Driver = .empty() // Register success - public var loginSuccess: Driver? + var loginSuccess: Driver? } } @@ -169,9 +249,10 @@ extension CenterAccountRegisterViewModel { // MARK: 최종 회원가입 버튼 let registerResult = input .completeButtonClicked - .flatMap { [unowned self] _ in - self.authUseCase - .registerCenterAccount(registerState: self.stateObject) + .unretained(self) + .flatMap { (vm, _) in + vm.authUseCase + .registerCenterAccount(registerState: vm.stateObject) } .share() @@ -195,7 +276,7 @@ extension CenterAccountRegisterViewModel { loginSuccess .unretained(self) .subscribe(onNext: { (obj, _) in - obj.presentCompleteScreen() + obj.presentCompleteScreen?() }) .disposed(by: disposeBag) @@ -311,8 +392,6 @@ extension CenterAccountRegisterViewModel.Input: AuthBusinessOwnerInputable { } extension CenterAccountRegisterViewModel.Output: AuthBusinessOwnerOutputable { } // Id & Password -extension CenterAccountRegisterViewModel.Input: SetIdInputable { } -extension CenterAccountRegisterViewModel.Input: SetPasswordInputable { } -extension CenterAccountRegisterViewModel.Output: SetIdOutputable { } -extension CenterAccountRegisterViewModel.Output: SetPasswordOutputable { } +extension CenterAccountRegisterViewModel.Input: SetIdAndPasswordInputable { } +extension CenterAccountRegisterViewModel.Output: SetIdAndPasswordOutputable { } diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/Model/asd.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/Model/asd.swift new file mode 100644 index 00000000..47f934a3 --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/Model/asd.swift @@ -0,0 +1,33 @@ +// +// asd.swift +// AuthFeature +// +// Created by choijunios on 10/22/24. +// + +import Foundation + +enum PasswordValidationCase: Int, CaseIterable { + case characterCount + case alphabetAndNumberIncluded + case noEmptySpace + case unsuccessiveSame3words + + var indicatorText: String { + + switch self { + case .characterCount: + "8자~20자 사이" + case .alphabetAndNumberIncluded: + "영문자와 숫자 반드시 하나씩 포함" + case .noEmptySpace: + "공백 문자 사용 금지" + case .unsuccessiveSame3words: + "연속된 문자 3개 이상 사용 금지" + } + } + + static var items: [PasswordValidationCase] { + PasswordValidationCase.allCases.sorted { $0.rawValue < $1.rawValue } + } +} diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/AuthInOutStreamManager+Signin.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/AuthInOutStreamManager+Signin.swift deleted file mode 100644 index 9a71a863..00000000 --- a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/AuthInOutStreamManager+Signin.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// AuthInOutStreamManager.swift -// AuthFeature -// -// Created by choijunios on 10/5/24. -// - -import UIKit -import PresentationCore -import Domain -import Core -import BaseFeature - - -import RxSwift -import RxCocoa - -extension AuthInOutStreamManager { - - static func idInOut( - input: SetIdInputable & AnyObject, - output: SetIdOutputable & AnyObject, - useCase: AuthInputValidationUseCase, - stateTracker: @escaping (String) -> () - ) { - - var output = output - - // MARK: Id - output.canCheckIdDuplication = input - .editingId - .map { [unowned useCase] id in - useCase.checkIdIsValid(id: id) - } - .asDriver(onErrorJustReturn: false) - - // 중복성 검사 - let idDuplicationValidation = input - .requestIdDuplicationValidation - .flatMap { [useCase] id in - - printIfDebug("[CenterRegisterViewModel] 중복성 검사 대상 id: \(id)") - - #if DEBUG - // 디버그시 아이디 중복체크 미실시 - print("✅ 디버그모드에서 아이디 중복검사 미실시") - // ☑️ 상태추적 ☑️ - stateTracker(id) - return Single.just(Result.success(())) - #endif - - return useCase.requestCheckingIdDuplication(id: id) - } - - output.idDuplicationValidation = Observable - .combineLatest(idDuplicationValidation, input.requestIdDuplicationValidation) - .map { [stateTracker] (result, id) in - switch result { - case .success: - printIfDebug("[CenterRegisterViewModel] 중복체크 결과: ✅ 성공") - // 🚀 상태추적 🚀 - stateTracker(id) - return true - case .failure(let error): - printIfDebug("❌ 아이디중복검사 실패 \n 에러내용: \(error.message)") - return false - } - } - .asDriver(onErrorJustReturn: false) - } - - static func passwordInOut( - input: SetPasswordInputable & AnyObject, - output: SetPasswordOutputable & AnyObject, - useCase: AuthInputValidationUseCase, - stateTracker: @escaping (String) -> ()) - { - var output = output - output.passwordValidation = input.editingPasswords - .filter { (pwd, cpwd) in !pwd.isEmpty && !cpwd.isEmpty } - .map { [unowned useCase] (pwd, cpwd) in - - printIfDebug("[CenterRegisterViewModel] \n 입력중인 비밀번호: \(pwd) \n 확인 비밀번호: \(cpwd)") - - let isValid = useCase.checkPasswordIsValid(password: pwd) - if !isValid { - printIfDebug("❌ 비밀번호가 유효하지 않습니다.") - return PasswordValidationState.invalidPassword - } else if pwd != cpwd { - printIfDebug("☑️ 비밀번호가 일치하지 않습니다.") - return PasswordValidationState.unMatch - } else { - printIfDebug("✅ 비밀번호가 일치합니다.") - stateTracker(pwd) - return PasswordValidationState.match - } - } - .asDriver(onErrorJustReturn: .invalidPassword) - } -} diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/Component/ValidationIndicator.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/Component/ValidationIndicator.swift new file mode 100644 index 00000000..03288065 --- /dev/null +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/Component/ValidationIndicator.swift @@ -0,0 +1,77 @@ +// +// ValidationIndicator.swift +// AuthFeature +// +// Created by choijunios on 10/22/24. +// + +import Foundation +import UIKit + +import DSKit + +class ValidationIndicator: UIView { + + enum State { + case valid + case invalid + } + + // View + let iconView: UIImageView = { + let view: UIImageView = .init() + return view + }() + + let label: IdleLabel = { + let label: IdleLabel = .init(typography: .Body3) + return label + }() + + init(labelText: String) { + + self.label.textString = labelText + + super.init(frame: .zero) + + setLayout() + } + required init?(coder: NSCoder) { nil } + + private func setLayout() { + + let mainStack: HStack = HStack( + [iconView, label, Spacer()], + spacing: 4, + alignment: .center, + distribution: .fill + ) + + self.addSubview(mainStack) + mainStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + + iconView.heightAnchor.constraint(equalToConstant: 24), + iconView.widthAnchor.constraint(equalTo: iconView.heightAnchor), + + mainStack.topAnchor.constraint(equalTo: self.topAnchor), + mainStack.leftAnchor.constraint(equalTo: self.leftAnchor), + mainStack.rightAnchor.constraint(equalTo: self.rightAnchor), + mainStack.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) + } + + func setState(_ state: State, animated: Bool = false) { + + let animateDuration: TimeInterval = animated ? 0.2 : 0 + + UIView.transition(with: self, duration: animateDuration, options: .transitionCrossDissolve) { + self.iconView.image = state == .valid ? AuthFeatureAsset.vsMark.image : AuthFeatureAsset.vfMark.image + } + + UIView.animate(withDuration: animateDuration) { + self.label.attrTextColor = state == .valid ? DSColor.green.color : DSColor.red200.color + } + } +} diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/SetIdPasswordViewController.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/SetIdPasswordViewController.swift index 1fdedbd8..b7f9aa9d 100644 --- a/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/SetIdPasswordViewController.swift +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/AccountRegister/View/SetIdPasswordViewController.swift @@ -15,32 +15,34 @@ import BaseFeature import RxSwift import RxCocoa - -public protocol SetIdInputable { - var editingId: BehaviorRelay { get set } - var requestIdDuplicationValidation: PublishRelay { get set } -} - -public protocol SetIdOutputable { - var canCheckIdDuplication: Driver? { get set } - var idDuplicationValidation: Driver? { get set } -} - -public protocol SetPasswordInputable { - var editingPasswords: PublishRelay<(pwd: String, cpwd: String)> { get set } +protocol SetIdAndPasswordInputable { + + // Id + var editingId: PublishSubject { get set } + var isIdDuplicatedButtonPressed: PublishSubject { get set } + + // Password + var editingPassword: PublishSubject { get set } + var checkingPassword: BehaviorSubject { get set } } -public protocol SetPasswordOutputable { - var passwordValidation: Driver? { get set } +protocol SetIdAndPasswordOutputable { + + // Id + var idValidationResult: Driver { get } + var idDuplicationCheckResult: Driver { get } + + // Password + var passwordValidationState: Driver { get } } class SetIdPasswordViewController: BaseViewController -where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, - T.Output: SetIdOutputable & SetPasswordOutputable, T: BaseViewModel { +where T.Input: SetIdAndPasswordInputable & PageProcessInputable, + T.Output: SetIdAndPasswordOutputable, T: BaseViewModel { // View - private let processTitleLabel: IdleLabel = { + let processTitleLabel: IdleLabel = { let label = IdleLabel(typography: .Heading2) label.textString = "아이디와 비밀번호를 설정해주세요." label.textAlignment = .left @@ -48,44 +50,39 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, }() // MARK: Id 입력 - private let idLabel: IdleLabel = { + let idLabel: IdleLabel = { let label = IdleLabel(typography: .Subtitle4) label.textString = "아이디 설정" label.attrTextColor = DSColor.gray500.color label.textAlignment = .left return label }() - private let idField: IFType1 = { - - let textField = IFType1( - placeHolderText: "아이디를 입력해주세요", - submitButtonText: "중복 확인" - ) - + let idField: IFType1 = { + let textField = IFType1(placeHolderText: "아이디를 입력해주세요", submitButtonText: "중복 확인") textField.idleTextField.isCompleteImageAvailable = false - return textField }() - private let thisIsValidIdLabel: ResizableUILabel = { - - let label = ResizableUILabel() - label.font = DSKitFontFamily.Pretendard.semiBold.font(size: 12) - label.text = "사용 가능한 아이디입니다." - label.textColor = DSKitAsset.Colors.gray300.color + let idGuideLabel: IdleLabel = { + let label = IdleLabel(typography: .Body3) + label.textString = "* 아이디는 아래의 조건에 맞추어주세요." + label.attrTextColor = DSColor.gray500.color label.textAlignment = .left - return label }() + // MARK: 아이디 검증 라벨 + let idValidationIndicator: ValidationIndicator = .init(labelText: "6자~20자 사이") + + // MARK: 비밀번호 입력 - private let passwordLabel: IdleLabel = { + let passwordLabel: IdleLabel = { let label = IdleLabel(typography: .Subtitle4) label.textString = "비밀번호 설정" label.attrTextColor = DSColor.gray500.color label.textAlignment = .left return label }() - private let passwordField: IdleOneLineInputField = { + let passwordField: IdleOneLineInputField = { let textField = IdleOneLineInputField( placeHolderText: "비밀번호를 입력해주세요." @@ -93,26 +90,32 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, return textField }() - private let thisIsValidPasswordLabel: ResizableUILabel = { - - let label = ResizableUILabel() - label.font = DSKitFontFamily.Pretendard.semiBold.font(size: 12) - label.text = "사용 가능한 비밀번호입니다." - label.textColor = DSKitAsset.Colors.gray300.color + let passwordGuideLabel: IdleLabel = { + let label = IdleLabel(typography: .Body3) + label.textString = "* 비밀번호는 아래의 조건에 맞추어주세요." + label.attrTextColor = DSColor.gray500.color label.textAlignment = .left - return label }() + // MARK: 비밀번호 검증 라벨 + let passwordValidationIndicator: [PasswordValidationCase: ValidationIndicator] = { + var dict: [PasswordValidationCase: ValidationIndicator] = [:] + for item in PasswordValidationCase.items { + dict[item] = ValidationIndicator(labelText: item.indicatorText) + } + return dict + }() + // MARK: 비밀번호 확인 입력 - private let checlPasswordLabel: IdleLabel = { + let checkPasswordLabel: IdleLabel = { let label = IdleLabel(typography: .Subtitle4) label.textString = "비밀번호 확인" label.attrTextColor = DSColor.gray500.color label.textAlignment = .left return label }() - private let checkPasswordField: IdleOneLineInputField = { + let checkPasswordField: IdleOneLineInputField = { let textField = IdleOneLineInputField( placeHolderText: "비밀번호를 한번 더 입력해주세요." @@ -121,13 +124,13 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, return textField }() - private let buttonContainer: PrevOrNextContainer = { + let buttonContainer: PrevOrNextContainer = { let button = PrevOrNextContainer() button.nextButton.label.textString = "완료" return button }() - public init(viewModel: T) { + init(viewModel: T) { super.init(nibName: nil, bundle: nil) @@ -141,7 +144,7 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, required init?(coder: NSCoder) { fatalError() } - public override func viewDidLoad() { + override func viewDidLoad() { view.backgroundColor = .clear } @@ -149,66 +152,120 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, private func setAppearance() { } private func setAutoLayout() { + + // pw validation indicators + + let pwValidationIndicators: VStack = VStack( + PasswordValidationCase.items.compactMap { item in passwordValidationIndicator[item] }, + spacing: 4, + alignment: .fill + ) + + let scrollView: UIScrollView = .init() + let scrollView_contentGuide = scrollView.contentLayoutGuide + let scrollView_frameGuide = scrollView.frameLayoutGuide + let contentView: UIView = .init() + + scrollView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + + contentView.widthAnchor.constraint(equalTo: scrollView_frameGuide.widthAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView_contentGuide.topAnchor), + contentView.leftAnchor.constraint(equalTo: scrollView_contentGuide.leftAnchor), + contentView.rightAnchor.constraint(equalTo: scrollView_contentGuide.rightAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView_contentGuide.bottomAnchor), + ]) - view.layoutMargins = .init(top: 28, left: 20, bottom: 0, right: 20) + contentView.layoutMargins = .init(top: 28,left: 20, bottom: 48, right: 20) [ processTitleLabel, idLabel, idField, - thisIsValidIdLabel, + idGuideLabel, + idValidationIndicator, passwordLabel, passwordField, - thisIsValidPasswordLabel, - checlPasswordLabel, + passwordGuideLabel, + pwValidationIndicators, + checkPasswordLabel, checkPasswordField, - buttonContainer, ].forEach { - view.addSubview($0) $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) } NSLayoutConstraint.activate([ - - processTitleLabel.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - processTitleLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - processTitleLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - idLabel.topAnchor.constraint(equalTo: processTitleLabel.bottomAnchor, constant: 32), - idLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - idLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + processTitleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + processTitleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + processTitleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + + idLabel.topAnchor.constraint(equalTo: processTitleLabel.layoutMarginsGuide.topAnchor, constant: 28), + idLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + idLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), idField.topAnchor.constraint(equalTo: idLabel.bottomAnchor, constant: 4), - idField.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - idField.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + idField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + idField.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + + idGuideLabel.topAnchor.constraint(equalTo: idField.bottomAnchor, constant: 12), + idGuideLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + idGuideLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - thisIsValidIdLabel.topAnchor.constraint(equalTo: idField.bottomAnchor, constant: 6), - thisIsValidIdLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - thisIsValidIdLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + idValidationIndicator.topAnchor.constraint(equalTo: idGuideLabel.bottomAnchor, constant: 6), + idValidationIndicator.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + idValidationIndicator.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - passwordLabel.topAnchor.constraint(equalTo: idField.bottomAnchor, constant: 32), - passwordLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - passwordLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + passwordLabel.topAnchor.constraint(equalTo: idValidationIndicator.bottomAnchor, constant: 24), + passwordLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + passwordLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), passwordField.topAnchor.constraint(equalTo: passwordLabel.bottomAnchor, constant: 6), - passwordField.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - passwordField.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + passwordField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + passwordField.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - thisIsValidPasswordLabel.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 6), - thisIsValidPasswordLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - thisIsValidPasswordLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + passwordGuideLabel.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 12), + passwordGuideLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + passwordGuideLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + + pwValidationIndicators.topAnchor.constraint(equalTo: passwordGuideLabel.bottomAnchor, constant: 6), + pwValidationIndicators.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + pwValidationIndicators.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + + checkPasswordLabel.topAnchor.constraint(equalTo: pwValidationIndicators.bottomAnchor, constant: 24), + checkPasswordLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + checkPasswordLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + + checkPasswordField.topAnchor.constraint(equalTo: checkPasswordLabel.bottomAnchor, constant: 6), + checkPasswordField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + checkPasswordField.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + checkPasswordField.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor) + ]) + + + [ + scrollView, - checlPasswordLabel.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 32), - checlPasswordLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - checlPasswordLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + buttonContainer, + ].forEach { + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ - checkPasswordField.topAnchor.constraint(equalTo: checlPasswordLabel.bottomAnchor, constant: 6), - checkPasswordField.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - checkPasswordField.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: buttonContainer.topAnchor, constant: -12), buttonContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -14), - buttonContainer.leftAnchor.constraint(equalTo: view.layoutMarginsGuide.leftAnchor), - buttonContainer.rightAnchor.constraint(equalTo: view.layoutMarginsGuide.rightAnchor), + buttonContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20), + buttonContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20), ]) } @@ -216,8 +273,10 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, idField.button.setEnabled(false) - thisIsValidIdLabel.isHidden = true - thisIsValidPasswordLabel.isHidden = true + idValidationIndicator.setState(.invalid) + passwordValidationIndicator.values.forEach { indicator in + indicator.setState(.invalid) + } // - CTA버튼 비활성화 buttonContainer.nextButton.setEnabled(false) @@ -230,21 +289,32 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, // MARK: Input let input = viewModel.input - // 현재 입력중인 정보 전송 + + // Id idField.idleTextField.textField.rx.text .compactMap{ $0 } .bind(to: input.editingId) .disposed(by: disposeBag) - Observable - .combineLatest( - passwordField.eventPublisher, - checkPasswordField.eventPublisher - ) - .map({ ($0, $1) }) - .bind(to: input.editingPasswords) + idField.button.eventPublisher + .mapToVoid() + .bind(to: input.isIdDuplicatedButtonPressed) + .disposed(by: disposeBag) + + // Password + passwordField + .textField.rx.text + .compactMap{ $0 } + .bind(to: input.editingPassword) .disposed(by: disposeBag) + checkPasswordField + .textField.rx.text + .compactMap{ $0 } + .bind(to: input.checkingPassword) + .disposed(by: disposeBag) + + // Navigation buttonContainer.nextBtnClicked .asObservable() .bind(to: input.completeButtonClicked) @@ -255,63 +325,84 @@ where T.Input: SetIdInputable & SetPasswordInputable & PageProcessInputable, .bind(to: input.prevButtonClicked) .disposed(by: disposeBag) - // id 중복확인 요청 버튼 - idField.eventPublisher - .map { [weak self] in - // 증복검사 실행시 아이디 입력 필드 비활성화 - self?.idField.idleTextField.setEnabled(false) - self?.idField.button.setEnabled(false) - return $0 - } - .bind(to: input.requestIdDuplicationValidation) - .disposed(by: disposeBag) // MARK: Output let output = viewModel.output // 중복확인이 가능한 아이디인가? output - .canCheckIdDuplication? - .drive(onNext: { [weak self] in - self?.idField.button.setEnabled($0) + .idValidationResult + .drive(onNext: { [weak self] isValid in + + guard let self else { return } + + // 검증 라벨 색상변경 + idValidationIndicator.setState(isValid ? .valid : .invalid) + + // 중복확인버튼 활성화 + idField.button.setEnabled(isValid) }) .disposed(by: disposeBag) - // 아이디 중복확인 결과 - let idDuplicationValidation = output - .idDuplicationValidation? - .map { [weak self] isSuccess in - - self?.idField.idleTextField.setEnabled(true) + let idDuplicationResult = output + .idDuplicationCheckResult + .asObservable() + .share() + + idDuplicationResult + .subscribe(onNext: { [weak self] isValid in - if !isSuccess { - self?.idField.idleTextField.textField.textString = "" - self?.showAlert(vo: .init( - title: "사용불가한 아이디", - message: "다른 아이디를 사용해주세요.") - ) - } + guard let self else { return } - return isSuccess - } - .asObservable() ?? .empty() + // 비밀번호 필드 활성화 + passwordField.setEnabled(isValid) + checkPasswordField.setEnabled(isValid) + }) + .disposed(by: disposeBag) - // 비밀번호 검증 결과 let passwordValidationResult = output - .passwordValidation? - .map { state in - state == .match - } - .asObservable() ?? .empty() + .passwordValidationState + .asObservable() + .share() + + passwordValidationResult + .subscribe(onNext: { + [weak self] state in + + guard let self else { return } + + // 비밀번호 체킹 상태 업데이트 + passwordValidationIndicator[.characterCount]?.setState( + state.characterCount == .valid ? .valid : .invalid + ) + passwordValidationIndicator[.alphabetAndNumberIncluded]?.setState( + state.alphabetAndNumberIncluded == .valid ? .valid : .invalid + ) + passwordValidationIndicator[.noEmptySpace]?.setState( + state.noEmptySpace == .valid ? .valid : .invalid + ) + passwordValidationIndicator[.unsuccessiveSame3words]?.setState( + state.unsuccessiveSame3words == .valid ? .valid : .invalid + ) + }) + .disposed(by: disposeBag) + // id, password 유효성 검사 Observable .combineLatest( - idDuplicationValidation, + idDuplicationResult, passwordValidationResult ) - .map { $0 && $1 } - .subscribe(onNext: { [weak self] in self?.buttonContainer.nextButton.setEnabled($0) }) + .map { idIsValid, passwordCheckingState in + idIsValid && passwordCheckingState.isValid + } + .subscribe(onNext: { [weak self] isValid in + + guard let self else { return } + + buttonContainer.nextButton.setEnabled(isValid) + }) .disposed(by: disposeBag) } diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/CenterSetupNewPasswordViewModel.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/CenterSetupNewPasswordViewModel.swift index 672a557e..7a7bcf38 100644 --- a/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/CenterSetupNewPasswordViewModel.swift +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/CenterSetupNewPasswordViewModel.swift @@ -36,13 +36,7 @@ class CenterSetupNewPasswordViewModel: BaseViewModel, ViewModelType { super.init() // 비밀번호 - AuthInOutStreamManager.passwordInOut( - input: input, - output: output, - useCase: inputValidationUseCase) { [weak self] validPassword in - // 🚀 상태추적 🚀 - self?.validPassword = validPassword - } + passwordValidationBinding() // 휴대전화 인증 AuthInOutStreamManager.validatePhoneNumberInOut( @@ -108,6 +102,37 @@ class CenterSetupNewPasswordViewModel: BaseViewModel, ViewModelType { } } +extension CenterSetupNewPasswordViewModel { + + func passwordValidationBinding() { + + // Passwords + output.passwordValidationState = Observable + .combineLatest( + input.editingPassword, + input.checkingPassword + ) + .unretained(self) + .map { (vm, passwords) in + + let (editing, checking) = passwords + + let stateObject: PasswordValidationState = vm.inputValidationUseCase + .checkPasswordIsValid(password: editing) + + stateObject.setEqualState(state: editing == checking) + + // 가장 최근 비밀번호 저장 + vm.validPassword = editing + + printIfDebug(stateObject.description) + + return stateObject + } + .asDriver(onErrorDriveWith: .never()) + } +} + extension CenterSetupNewPasswordViewModel { class Input { @@ -124,7 +149,8 @@ extension CenterSetupNewPasswordViewModel { public var requestValidationForAuthNumber: PublishRelay = .init() // Password - public var editingPasswords: PublishRelay<(pwd: String, cpwd: String)> = .init() + var editingPassword: PublishSubject = .init() + var checkingPassword: BehaviorSubject = .init(value: "") // Change password public var changePasswordButtonClicked: PublishRelay = .init() @@ -142,7 +168,7 @@ extension CenterSetupNewPasswordViewModel { public var authNumberValidation: Driver? // Password - public var passwordValidation: Driver? + var passwordValidationState: Driver = .empty() public var loginSuccess: Driver? } @@ -152,8 +178,7 @@ extension CenterSetupNewPasswordViewModel { extension CenterSetupNewPasswordViewModel.Input: AuthPhoneNumberInputable { } extension CenterSetupNewPasswordViewModel.Output: AuthPhoneNumberOutputable { } -extension CenterSetupNewPasswordViewModel.Input: SetPasswordInputable { } -extension CenterSetupNewPasswordViewModel.Output: SetPasswordOutputable { } - extension CenterSetupNewPasswordViewModel.Input: ChangePasswordSuccessInputable { } +extension CenterSetupNewPasswordViewModel.Output: ChangePasswordSuccessOutputable { } + extension CenterSetupNewPasswordViewModel.Input: PageProcessInputable { } diff --git a/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/View/ValidateNewPasswordViewController.swift b/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/View/ValidateNewPasswordViewController.swift index b215375b..8eb78f20 100644 --- a/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/View/ValidateNewPasswordViewController.swift +++ b/project/Projects/Presentation/Feature/Auth/Sources/Center/SetNewPassword/View/ValidateNewPasswordViewController.swift @@ -6,20 +6,27 @@ // import UIKit + +import BaseFeature import DSKit +import PresentationCore +import Domain + import RxCocoa import RxSwift -import PresentationCore -import BaseFeature -public protocol ChangePasswordSuccessInputable { +protocol ChangePasswordSuccessInputable { + var editingPassword: PublishSubject { get set } + var checkingPassword: BehaviorSubject { get set } var changePasswordButtonClicked: PublishRelay { get } } +protocol ChangePasswordSuccessOutputable { + var passwordValidationState: Driver { get } +} class ValidateNewPasswordViewController: UIViewController -where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, - T.Output: SetPasswordOutputable { +where T.Input: ChangePasswordSuccessInputable, T.Output: ChangePasswordSuccessOutputable { let viewModel: T @@ -45,17 +52,25 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, ) return textField }() - private let thisIsValidPasswordLabel: IdleLabel = { - let label = IdleLabel(typography: .caption) - label.textString = "* 사용 가능한 비밀번호입니다." - label.attrTextColor = DSKitAsset.Colors.gray300.color + let passwordGuideLabel: IdleLabel = { + let label = IdleLabel(typography: .Body3) + label.textString = "* 비밀번호는 아래의 조건에 맞추어주세요." + label.attrTextColor = DSColor.gray500.color label.textAlignment = .left - label.alpha = 0 return label }() + // MARK: 비밀번호 검증 라벨 + let passwordValidationIndicator: [PasswordValidationCase: ValidationIndicator] = { + var dict: [PasswordValidationCase: ValidationIndicator] = [:] + for item in PasswordValidationCase.items { + dict[item] = ValidationIndicator(labelText: item.indicatorText) + } + return dict + }() + // MARK: 비밀번호 확인 입력 - private let checlPasswordLabel: IdleLabel = { + private let checkPasswordLabel: IdleLabel = { let label = IdleLabel(typography: .Subtitle4) label.textString = "비밀번호 확인" label.attrTextColor = DSKitAsset.Colors.gray500.color @@ -63,11 +78,7 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, return label }() private let checkPasswordField: IdleOneLineInputField = { - - let textField = IdleOneLineInputField( - placeHolderText: "비밀번호를 한번 더 입력해주세요." - ) - + let textField = IdleOneLineInputField(placeHolderText: "비밀번호를 한번 더 입력해주세요.") return textField }() private let passwordDoesntMathLabel: IdleLabel = { @@ -112,12 +123,21 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, private func setAutoLayout() { + // pw validation indicators + + let pwValidationIndicators: VStack = VStack( + PasswordValidationCase.items.compactMap { item in passwordValidationIndicator[item] }, + spacing: 4, + alignment: .fill + ) + [ processTitle, passwordLabel, passwordField, - thisIsValidPasswordLabel, - checlPasswordLabel, + passwordGuideLabel, + pwValidationIndicators, + checkPasswordLabel, checkPasswordField, passwordDoesntMathLabel, ctaButton, @@ -140,15 +160,19 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, passwordField.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), passwordField.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - thisIsValidPasswordLabel.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 2), - thisIsValidPasswordLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - thisIsValidPasswordLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + passwordGuideLabel.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 12), + passwordGuideLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + passwordGuideLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - checlPasswordLabel.topAnchor.constraint(equalTo: thisIsValidPasswordLabel.bottomAnchor, constant: 12), - checlPasswordLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - checlPasswordLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + pwValidationIndicators.topAnchor.constraint(equalTo: passwordGuideLabel.bottomAnchor, constant: 6), + pwValidationIndicators.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + pwValidationIndicators.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - checkPasswordField.topAnchor.constraint(equalTo: checlPasswordLabel.bottomAnchor, constant: 6), + checkPasswordLabel.topAnchor.constraint(equalTo: pwValidationIndicators.bottomAnchor, constant: 12), + checkPasswordLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + checkPasswordLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + + checkPasswordField.topAnchor.constraint(equalTo: checkPasswordLabel.bottomAnchor, constant: 6), checkPasswordField.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), checkPasswordField.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), @@ -163,6 +187,11 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, } private func initialUISettuing() { + + passwordValidationIndicator.values.forEach { indicator in + indicator.setState(.invalid) + } + // - CTA버튼 비활성화 ctaButton.setEnabled(false) } @@ -172,13 +201,17 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, // MARK: Input let input = viewModel.input - Observable - .combineLatest( - passwordField.eventPublisher, - checkPasswordField.eventPublisher - ) - .map({ ($0, $1) }) - .bind(to: input.editingPasswords) + // Password + passwordField + .textField.rx.text + .compactMap{ $0 } + .bind(to: input.editingPassword) + .disposed(by: disposeBag) + + checkPasswordField + .textField.rx.text + .compactMap{ $0 } + .bind(to: input.checkingPassword) .disposed(by: disposeBag) ctaButton @@ -193,40 +226,29 @@ where T.Input: SetPasswordInputable & ChangePasswordSuccessInputable, // MARK: Output let output = viewModel.output - // 비밀번호 검증 output - .passwordValidation? - .drive(onNext: { [weak self] validationState in + .passwordValidationState + .drive(onNext: { [weak self] state in guard let self else { return } - switch validationState { - case .invalidPassword: - thisIsValidPasswordLabel.alpha = 0 - onPasswordUnMatched() - case .unMatch: - thisIsValidPasswordLabel.alpha = 1 - passwordDoesntMathLabel.alpha = 1 - onPasswordUnMatched() - case .match: - thisIsValidPasswordLabel.alpha = 1 - passwordDoesntMathLabel.alpha = 0 - onPasswordMatched() - ctaButton.setEnabled(true) - } + // 비밀번호 체킹 상태 업데이트 + passwordValidationIndicator[.characterCount]?.setState( + state.characterCount == .valid ? .valid : .invalid + ) + passwordValidationIndicator[.alphabetAndNumberIncluded]?.setState( + state.alphabetAndNumberIncluded == .valid ? .valid : .invalid + ) + passwordValidationIndicator[.noEmptySpace]?.setState( + state.noEmptySpace == .valid ? .valid : .invalid + ) + passwordValidationIndicator[.unsuccessiveSame3words]?.setState( + state.unsuccessiveSame3words == .valid ? .valid : .invalid + ) + + // 확인버튼 활성화 + ctaButton.setEnabled(state.isValid) }) .disposed(by: disposeBag) } - - private func onPasswordMatched() { - - passwordField.setState(state: .complete) - checkPasswordField.setState(state: .complete) - } - - private func onPasswordUnMatched() { - - passwordField.setState(state: .editing) - checkPasswordField.setState(state: .editing) - } } diff --git a/project/Projects/Testing/Sources/Domain/Mock_Domain.swift b/project/Projects/Testing/Sources/Domain/Mock_Domain.swift index 55620108..a94baa02 100644 --- a/project/Projects/Testing/Sources/Domain/Mock_Domain.swift +++ b/project/Projects/Testing/Sources/Domain/Mock_Domain.swift @@ -6,3 +6,43 @@ // import Foundation +import Domain + + +import RxSwift + +class MockAuthUseCase: AuthUseCase { + + func registerCenterAccount(registerState: Domain.CenterRegisterState) -> RxSwift.Single> { + .just(.success(())) + } + + func loginCenterAccount(id: String, password: String) -> RxSwift.Single> { + .just(.success(())) + } + + func checkCenterJoinStatus() -> RxSwift.Single> { + .just(.success(.mock)) + } + + func setNewPassword(phoneNumber: String, password: String) -> RxSwift.Single> { + .just(.success(())) + } + + func registerWorkerAccount(registerState: Domain.WorkerRegisterState) -> RxSwift.Single> { + .just(.success(())) + } + + func loginWorkerAccount(phoneNumber: String, authNumber: String) -> RxSwift.Single> { + .just(.success(())) + } +} + +extension CenterJoinStatusInfoVO { + static let mock: Self = .init( + id: "123", + managerName: "관리자 성험", + phoneNumber: "010-1111-2222", + centerManagerAccountStatus: .approved + ) +} diff --git a/project/Projects/Testing/Sources/MockAssemblies.swift b/project/Projects/Testing/Sources/MockAssemblies.swift index b4d1e8e1..c02649d0 100644 --- a/project/Projects/Testing/Sources/MockAssemblies.swift +++ b/project/Projects/Testing/Sources/MockAssemblies.swift @@ -24,7 +24,13 @@ public let MockAssemblies: [Assembly] = [ struct MockDomainAssembly: Assembly { func assemble(container: Container) { + container.register(AuthInputValidationUseCase.self) { _ in + DefaultAuthInputValidationUseCase() + } + container.register(AuthUseCase.self) { _ in + MockAuthUseCase() + } } } diff --git a/project/graph.png b/project/graph.png index 1727f8d7..0a11ca7f 100644 Binary files a/project/graph.png and b/project/graph.png differ