From b36545164bf98f738e4668e94da3b2ebfd7ddfe2 Mon Sep 17 00:00:00 2001 From: Melt Date: Thu, 23 Jan 2025 02:19:23 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20#467=20-=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20SideEffect=20Prese?= =?UTF-8?q?ntation=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Error/PhoneVerifyError.swift | 3 + .../Sources/UseCase/PhoneVerifyUseCase.swift | 19 ++-- .../PhoneVerifyScene/PhoneVerifyView.swift | 91 +++++++++++++++---- .../PhoneVerifyViewModel.swift | 66 +++++++++++--- 4 files changed, 136 insertions(+), 43 deletions(-) diff --git a/SOPT-iOS/Projects/Domain/Sources/Error/PhoneVerifyError.swift b/SOPT-iOS/Projects/Domain/Sources/Error/PhoneVerifyError.swift index 3e232fd8..6501b5db 100644 --- a/SOPT-iOS/Projects/Domain/Sources/Error/PhoneVerifyError.swift +++ b/SOPT-iOS/Projects/Domain/Sources/Error/PhoneVerifyError.swift @@ -9,6 +9,9 @@ import Foundation public enum PhoneVerifyError: Error { + case userNotFound + case alreadyExist case invalidVerifyCode //인증 내역은 유효하나 제출한 “인증 번호”가 일치하지 않을 경우 case timeout // 시간 초과 + case unknown(Error) } diff --git a/SOPT-iOS/Projects/Domain/Sources/UseCase/PhoneVerifyUseCase.swift b/SOPT-iOS/Projects/Domain/Sources/UseCase/PhoneVerifyUseCase.swift index e2cbed0a..a0d18b70 100644 --- a/SOPT-iOS/Projects/Domain/Sources/UseCase/PhoneVerifyUseCase.swift +++ b/SOPT-iOS/Projects/Domain/Sources/UseCase/PhoneVerifyUseCase.swift @@ -11,19 +11,21 @@ import Combine import Core public struct PhoneVerifyPolicy { - public let phoneNumberCount: Int + public let phoneMaxLength: Int + public let codeMaxLength: Int private let _timeLimit: Duration public var timeLimit: Int { Int(_timeLimit.components.seconds) } - public init(phoneNumberCount: Int, timeLimit: Duration) { - self.phoneNumberCount = phoneNumberCount + public init(phoneMaxLength: Int, codeMaxLength: Int, timeLimit: Duration) { + self.phoneMaxLength = phoneMaxLength + self.codeMaxLength = codeMaxLength self._timeLimit = timeLimit } } extension PhoneVerifyPolicy { - static let `default` = Self(phoneNumberCount: 11, timeLimit: .seconds(180)) - static let stub = Self(phoneNumberCount: 11, timeLimit: .seconds(10)) + static let `default` = Self(phoneMaxLength: 11, codeMaxLength: 6, timeLimit: .seconds(180)) + static let stub = Self(phoneMaxLength: 11, codeMaxLength: 6, timeLimit: .seconds(10)) } public protocol PhoneVerifyUseCase { @@ -78,10 +80,7 @@ public class StubPhoneVerifyUseCase: PhoneVerifyUseCase { } public func verify(_ model: PhoneVerifyModel) -> AnyPublisher { - return Just(()).eraseToAnyPublisher() + sideEffect.send(.userNotFound) + return Empty().eraseToAnyPublisher() } } - -extension StubPhoneVerifyUseCase: SignUpUseCase { - -} diff --git a/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyView.swift b/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyView.swift index b800e84d..f3375b06 100644 --- a/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyView.swift +++ b/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyView.swift @@ -8,17 +8,19 @@ import UIKit +import BaseFeatureDependency import DSKit import Core + final class PhoneVerifyView: UIView { public var viewModelInput: PhoneVerifyViewModel.Input { return .init( sendButtonTapped: sendButton.publisher(for: .touchUpInside).mapVoid().asDriver(), doneButtonTapped: doneButton.publisher(for: .touchUpInside).mapVoid().asDriver(), - phoneTextFieldText: phoneTextField.publisher(for: .editingChanged).map { $0.text ?? "" }.asDriver(), - codeTextFieldText: codeTextField.publisher(for: .editingChanged).map { $0.text ?? "" }.asDriver(), + phoneTextFieldText: phoneTextField.publisher(for: .editingChanged).compactMap { $0.text }.asDriver(), + codeTextFieldText: codeTextField.publisher(for: .editingChanged).compactMap { $0.text }.asDriver(), loginHelpButtonTapped: helpView.gesture().mapVoid().asDriver() ) } @@ -52,10 +54,24 @@ final class PhoneVerifyView: UIView { $0.textColor = DSKitAsset.Colors.gray10.color $0.layer.cornerRadius = 10 $0.layer.masksToBounds = true + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.clear.cgColor $0.addToolbar() $0.addLeftPadding(width: 20) } + private let phoneFailIcon = UIImageView().then { + $0.image = DSKitAsset.Assets.alertCircle.image.withTintColor(DSKitAsset.Colors.error.color) + $0.contentMode = .scaleAspectFit + $0.isHidden = true + } + + private let phoneFailLabel = UILabel().then { + $0.font = DSKitFontFamily.Suit.semiBold.font(size: 12) + $0.textColor = DSKitAsset.Colors.error.color + $0.isHidden = true + } + private let sendButton = AppImageTextButton(title: "전송하기").then { $0.layer.cornerRadius = 10 $0.layer.masksToBounds = true @@ -82,13 +98,13 @@ final class PhoneVerifyView: UIView { $0.text = "03:00" } - private let failIcon = UIImageView().then { + private let codeFailIcon = UIImageView().then { $0.image = DSKitAsset.Assets.alertCircle.image.withTintColor(DSKitAsset.Colors.error.color) $0.contentMode = .scaleAspectFit $0.isHidden = true } - private let failLabel = UILabel().then { + private let codeFailLabel = UILabel().then { $0.font = DSKitFontFamily.Suit.semiBold.font(size: 12) $0.textColor = DSKitAsset.Colors.error.color $0.isHidden = true @@ -153,10 +169,12 @@ final class PhoneVerifyView: UIView { descriptionLabel, phoneLabel, phoneTextField, + phoneFailIcon, + phoneFailLabel, sendButton, codeTextField, - failIcon, - failLabel, + codeFailIcon, + codeFailLabel, helpView, doneButton ) @@ -193,6 +211,18 @@ final class PhoneVerifyView: UIView { $0.height.equalTo(48) } + phoneFailIcon.snp.makeConstraints { + $0.top.equalTo(phoneTextField.snp.bottom).offset(8) + $0.leading.equalTo(phoneTextField) + $0.size.equalTo(14) + } + + phoneFailLabel.snp.makeConstraints { + $0.centerY.equalTo(phoneFailIcon) + $0.leading.equalTo(phoneFailIcon.snp.trailing).offset(4) + $0.trailing.equalTo(codeTextField) + } + sendButton.snp.makeConstraints { $0.centerY.equalTo(phoneTextField) $0.leading.equalTo(phoneTextField.snp.trailing).offset(7) @@ -202,7 +232,7 @@ final class PhoneVerifyView: UIView { } codeTextField.snp.makeConstraints { - $0.top.equalTo(phoneTextField.snp.bottom).offset(13) + $0.top.equalTo(phoneFailLabel.snp.bottom).offset(10) $0.leading.trailing.equalToSuperview().inset(24) $0.height.equalTo(phoneTextField) $0.bottom.lessThanOrEqualToSuperview() @@ -213,20 +243,20 @@ final class PhoneVerifyView: UIView { $0.trailing.equalToSuperview().inset(16) } - failIcon.snp.makeConstraints { + codeFailIcon.snp.makeConstraints { $0.top.equalTo(codeTextField.snp.bottom).offset(8) $0.leading.equalTo(codeTextField) $0.size.equalTo(14) } - failLabel.snp.makeConstraints { - $0.centerY.equalTo(failIcon) - $0.leading.equalTo(failIcon.snp.trailing).offset(4) + codeFailLabel.snp.makeConstraints { + $0.centerY.equalTo(codeFailIcon) + $0.leading.equalTo(codeFailIcon.snp.trailing).offset(4) $0.trailing.equalTo(codeTextField) } helpView.snp.makeConstraints { - $0.top.equalTo(failLabel.snp.bottom).offset(20) + $0.top.equalTo(codeFailLabel.snp.bottom).offset(20) $0.leading.trailing.equalTo(codeTextField) } @@ -272,15 +302,25 @@ extension PhoneVerifyView { .withUnretained(self) .sink { owner, isSent in let text = isSent ? "재전송하기" : "전송하기" + ToastUtils.showMDSToast(type: .success, text: "인증번호가 전송되었어요.") owner.sendButton.updateTitle(text) owner.codeTextField.isHidden = !isSent } .store(in: cancelBag) - output.showToast + output.phoneTextFieldText + .asDriver() + .withUnretained(self) + .sink { owner, text in + owner.phoneTextField.text = text + } + .store(in: cancelBag) + + output.codeTextFieldText + .asDriver() .withUnretained(self) .sink { owner, text in - + owner.codeTextField.text = text } .store(in: cancelBag) @@ -299,10 +339,17 @@ extension PhoneVerifyView { } .store(in: cancelBag) - output.failDescription + output.phoneFailDescription .withUnretained(self) .sink { owner, description in - owner.updateFailLabelUI(description) + owner.updateFailLabelUI(isCode: false, description) + } + .store(in: cancelBag) + + output.codeFailDescription + .withUnretained(self) + .sink { owner, description in + owner.updateFailLabelUI(isCode: true, description) } .store(in: cancelBag) @@ -329,11 +376,17 @@ extension PhoneVerifyView { } - private func updateFailLabelUI(_ description: String?) { + private func updateFailLabelUI(isCode: Bool, _ description: String?) { + let failLabel = isCode ? codeFailLabel : phoneFailLabel + let failIcon = isCode ? codeFailIcon : phoneFailIcon + let textfield = isCode ? codeTextField : phoneTextField failLabel.text = description failIcon.isHidden = description == nil failLabel.isHidden = description == nil - timeLeftLabel.textColor = description == nil ? DSKitAsset.Colors.white.color : DSKitAsset.Colors.error.color - codeTextField.layer.borderColor = description == nil ? UIColor.clear.cgColor : DSKitAsset.Colors.error.color.cgColor + textfield.layer.borderColor = description == nil ? UIColor.clear.cgColor : DSKitAsset.Colors.error.color.cgColor + + if isCode { + timeLeftLabel.textColor = description == nil ? DSKitAsset.Colors.white.color : DSKitAsset.Colors.error.color + } } } diff --git a/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyViewModel.swift b/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyViewModel.swift index 5b5eb10a..6ccf9ea2 100644 --- a/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyViewModel.swift +++ b/SOPT-iOS/Projects/Features/AuthFeature/Sources/PhoneVerifyScene/PhoneVerifyViewModel.swift @@ -33,11 +33,13 @@ public class PhoneVerifyViewModel: PhoneVerifyViewModelType { // MARK: - Outputs public struct Output { - let isSent = CurrentValueSubject(false) + let isSent = PassthroughSubject() let verifySuccess = PassthroughSubject() - let failDescription = PassthroughSubject() - let showToast = PassthroughSubject() + let phoneFailDescription = PassthroughSubject() + let codeFailDescription = PassthroughSubject() let timeLeft = PassthroughSubject() + let phoneTextFieldText = CurrentValueSubject("") + let codeTextFieldText = CurrentValueSubject("") let timerIsRunning = PassthroughSubject() let sendButtonIsEnabled = CurrentValueSubject(false) let doneButtonIsEnabled = CurrentValueSubject(false) @@ -69,26 +71,34 @@ extension PhoneVerifyViewModel { .sink { owner, err in switch err { case .invalidVerifyCode: - output.failDescription.send("인증번호가 올바르지 않습니다.") + output.codeFailDescription.send("인증번호가 올바르지 않습니다.") return case .timeout: owner.timerCancellable = nil - output.failDescription.send("3분이 초과되었어요. 인증번호를 다시 요청해주세요.") + output.codeFailDescription.send("3분이 초과되었어요. 인증번호를 다시 요청해주세요.") return + case .userNotFound: + output.phoneFailDescription.send("SOPT 활동 시 사용한 전화번호가 아니에요.") + case .alreadyExist: + output.phoneFailDescription.send("이미 가입된 전화번호예요.") + case .unknown(_): + output.codeFailDescription.send("알 수 없는 오류예요.") } } .store(in: cancelBag) input.sendButtonTapped + .throttle(for: 2, scheduler: RunLoop.main, latest: false) .handleEvents(receiveOutput: { _ in - output.isSent.send(true) - output.failDescription.send(nil) } + output.phoneFailDescription.send(nil) + output.codeFailDescription.send(nil) + } ) - .withLatestFrom(input.phoneTextFieldText) - .map { PhoneSendModel(name: nil, phone: $0, type: .register) } + .map { PhoneSendModel(name: nil, phone: output.phoneTextFieldText.value, type: .register) } .flatMap(useCase.send) .withUnretained(self) .sink { owner , _ in + output.isSent.send(true) output.timeLeft.send(owner.useCase.policy.timeLimit) owner.timerCancellable = owner.timerPublisher .autoconnect() @@ -101,17 +111,40 @@ extension PhoneVerifyViewModel { } output.timeLeft.send(counter) } - output.showToast.send("인증번호가 전송되었어요.") } .store(in: cancelBag) input.phoneTextFieldText - .map { $0.count >= self.useCase.policy.phoneNumberCount && $0.allSatisfy { $0.isNumber } } + .handleEvents(receiveOutput: { _ in + output.phoneFailDescription.send(nil) + }) + .withUnretained(self) + .map { $1.count >= $0.useCase.policy.phoneMaxLength && $1.allSatisfy { $0.isNumber } } .sink { output.sendButtonIsEnabled.send($0) } .store(in: cancelBag) + input.phoneTextFieldText + .withUnretained(self) + .filter { $1.count > $0.useCase.policy.phoneMaxLength } + .map { + let newValue = $1.prefix($0.useCase.policy.phoneMaxLength) + return String(newValue) + } + .sink { output.phoneTextFieldText.send($0) } + .store(in: cancelBag) + + input.codeTextFieldText + .withUnretained(self) + .filter { $1.count > $0.useCase.policy.codeMaxLength } + .map { + let newValue = $1.prefix($0.useCase.policy.codeMaxLength) + return String(newValue) + } + .sink { output.codeTextFieldText.send($0) } + .store(in: cancelBag) + Publishers.CombineLatest( - input.codeTextFieldText, + output.codeTextFieldText, output.timerIsRunning ) .map { !$0.isEmpty && $1 } @@ -121,8 +154,13 @@ extension PhoneVerifyViewModel { input.doneButtonTapped .withLatestFrom(output.timerIsRunning) .filter { $0 } - .withLatestFrom(Publishers.Zip(input.phoneTextFieldText, input.codeTextFieldText)) - .map { PhoneVerifyModel(name: nil, phone: $0, code: $1, type: .register)} + .mapVoid() + .map { PhoneVerifyModel( + name: nil, + phone: output.phoneTextFieldText.value, + code: output.codeTextFieldText.value, + type: .register) + } .flatMap(useCase.verify) .withUnretained(self) .sink { owner, _ in