From b39e340c8becc85f9a2bedbd45bcb4637a8a3eca Mon Sep 17 00:00:00 2001 From: Jisu Kim <108998071+jisu15-kim@users.noreply.github.com> Date: Fri, 11 Oct 2024 02:23:48 +0900 Subject: [PATCH] =?UTF-8?q?[WEAV-74]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20-=20=ED=9A=8C=EC=82=AC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [WEAV-74] Drop Down View 생성 * [WEAV-74] 드롭다운, 회사 리스트 API 연결 * [WEAV-74] 없는 회사 선택 기능 구현 * [WEAV-74] 같은 회사 매칭 여부 선택 뷰 구현 * [WEAV-74] debug 전용 인증 건너뛰기 구현 * [WEAV-74] 회사 검색 뷰 로직 테스트코드 작성 * [CICD] Unit test Generate swift added. * [WEAV-74] 회사 리스트 드롭다운 페이지네이션 --- .github/workflows/unitTest.yml | 4 +- .../Sources/Navigation/NavigationStack.swift | 2 + .../CommonKit/Sources/Path/PathTypes.swift | 22 +- .../UnitTest/StringExtensionText.swift | 4 +- .../Network/CompanySearchResponse.swift | 19 ++ .../CompanyService/CompanyService.swift | 45 ++++ .../CompanyService/CompanyServiceMock.swift | 34 +++ .../Middleware/LogMiddleWare.swift | 14 +- .../Sources/CTAButton/CTAButton.swift | 4 +- .../Sources/DropDown/DropDownView.swift | 123 ++++++++++ .../Sources/TextInput/TextInputView.swift | 6 +- .../AuthPhoneInput/AuthPhoneInputView.swift | 21 ++ .../AuthPhoneVerify/AuthPhoneVerifyView.swift | 15 ++ .../AuthCompany/AuthCompanyIntent.swift | 153 ++++++++++++ .../AuthCompany/AuthCompanyModel.swift | 157 ++++++++++++ .../AuthCompany/AuthCompanyView.swift | 230 ++++++++++++++++++ .../AuthProfileAgeInputIntent.swift | 2 +- .../SignUp/UnitTest/AuthCompanyTest.swift | 91 +++++++ 18 files changed, 931 insertions(+), 15 deletions(-) create mode 100644 Projects/Core/Model/Sources/Network/CompanySearchResponse.swift create mode 100644 Projects/Core/NetworkKit/Sources/CompanyService/CompanyService.swift create mode 100644 Projects/Core/NetworkKit/Sources/CompanyService/CompanyServiceMock.swift create mode 100644 Projects/DesignSystem/DesignCore/Sources/DropDown/DropDownView.swift create mode 100644 Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyIntent.swift create mode 100644 Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyModel.swift create mode 100644 Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyView.swift create mode 100644 Projects/Features/SignUp/UnitTest/AuthCompanyTest.swift diff --git a/.github/workflows/unitTest.yml b/.github/workflows/unitTest.yml index a4ba6ed..99d88e9 100644 --- a/.github/workflows/unitTest.yml +++ b/.github/workflows/unitTest.yml @@ -14,7 +14,9 @@ jobs: run: | curl https://mise.run | sh mise install - + - name: Create Secret.swift file + run: | + echo '${{ secrets.SECRET_SWIFT }}' > ./Projects/Core/CoreKit/Sources/Secret.swift - name: Install Tuist dependencies run: mise x -- tuist install diff --git a/Projects/App/Sources/Navigation/NavigationStack.swift b/Projects/App/Sources/Navigation/NavigationStack.swift index ad0ba42..da4d864 100644 --- a/Projects/App/Sources/Navigation/NavigationStack.swift +++ b/Projects/App/Sources/Navigation/NavigationStack.swift @@ -34,6 +34,8 @@ extension PathType { AuthProfileGenderInputView() case .authProfileAge: AuthProfileAgeInputView() + case .authCompany: + AuthCompanyView() case .authName: AuthNameInputView() } diff --git a/Projects/Core/CommonKit/Sources/Path/PathTypes.swift b/Projects/Core/CommonKit/Sources/Path/PathTypes.swift index ba0249f..07e1055 100644 --- a/Projects/Core/CommonKit/Sources/Path/PathTypes.swift +++ b/Projects/Core/CommonKit/Sources/Path/PathTypes.swift @@ -18,6 +18,22 @@ public enum PathType: Hashable { public static var debugPreviewTypes: [PathType] = [ .designPreview, .main, + .signUp(.authPhoneInput), + .signUp( + .authPhoneVerify( + SMSSendResponse( + userType: .NEW, + authCodeId: "tempCode", + phoneNumber: "010-1234-5678" + ) + ) + ), + .signUp(.authAgreement), + .signUp(.authGreeting), + .signUp(.authProfileGender), + .signUp(.authProfileAge), + .signUp(.authCompany), + .signUp(.authName), .signUp(.authPhoneInput) ] #endif @@ -35,6 +51,7 @@ public enum PathType: Hashable { case .authGreeting: return "가입 후 환영" case .authProfileGender: return "성별 입력" case .authProfileAge: return "나이 입력" + case .authCompany: return "내 직장 입력" case .authName: return "이름 입력" } } @@ -49,6 +66,7 @@ public enum SignUpSubViewType: Hashable { case authGreeting case authProfileGender case authProfileAge + case authCompany case authName public static func == (lhs: SignUpSubViewType, rhs: SignUpSubViewType) -> Bool { @@ -69,8 +87,10 @@ public enum SignUpSubViewType: Hashable { hasher.combine(4) case .authProfileAge: hasher.combine(5) - case .authName: + case .authCompany: hasher.combine(6) + case .authName: + hasher.combine(7) } } } diff --git a/Projects/Core/CoreKit/UnitTest/StringExtensionText.swift b/Projects/Core/CoreKit/UnitTest/StringExtensionText.swift index 6c8cace..015bf3b 100644 --- a/Projects/Core/CoreKit/UnitTest/StringExtensionText.swift +++ b/Projects/Core/CoreKit/UnitTest/StringExtensionText.swift @@ -43,9 +43,9 @@ final class PhoneNumberTests: XCTestCase { "010123456789".isValidPhoneNumber(), "유효하지 않은 번호 형식임에도 불구하고 true가 반환됨" ) - XCTAssertFalse( + XCTAssertTrue( "010-1234-5678".isValidPhoneNumber(), - "하이픈이 포함된 경우 유효하지 않은 번호 형식임에도 불구하고 true가 반환됨" + "하이픈이 포함되었으나 유효한 번호인 경우 true를 반환해야 함" ) // 텍스트가 포함된 경우 diff --git a/Projects/Core/Model/Sources/Network/CompanySearchResponse.swift b/Projects/Core/Model/Sources/Network/CompanySearchResponse.swift new file mode 100644 index 0000000..6bf4781 --- /dev/null +++ b/Projects/Core/Model/Sources/Network/CompanySearchResponse.swift @@ -0,0 +1,19 @@ +// +// CompanySearchResponse.swift +// CommonKit +// +// Created by 김지수 on 10/9/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation + +public struct CompanySearchResponse { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} diff --git a/Projects/Core/NetworkKit/Sources/CompanyService/CompanyService.swift b/Projects/Core/NetworkKit/Sources/CompanyService/CompanyService.swift new file mode 100644 index 0000000..576e265 --- /dev/null +++ b/Projects/Core/NetworkKit/Sources/CompanyService/CompanyService.swift @@ -0,0 +1,45 @@ +// +// CompanyService.swift +// CommonKit +// +// Created by 김지수 on 10/9/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import OpenapiGenerated +import Model + +//MARK: - Service Protocol +public protocol CompanyServiceProtocol { + func requestSearchCompany( + keyword: String, + next: String? + ) async throws -> ([CompanySearchResponse], String?) +} + +//MARK: - Service +public final class CompanyService { + public static var shared = CompanyService() + private init() {} +} + +extension CompanyService: CompanyServiceProtocol { + public func requestSearchCompany( + keyword: String, + next: String? = nil + ) async throws -> ([CompanySearchResponse], String?) { + let response = try await client.searchCompanies( + query: .init(name: keyword, next: next) + ).ok.body.json + + let result = response.companies.map { + CompanySearchResponse( + id: $0.id, + name: $0.name + ) + } + let next = response.next + return (result, next) + } +} diff --git a/Projects/Core/NetworkKit/Sources/CompanyService/CompanyServiceMock.swift b/Projects/Core/NetworkKit/Sources/CompanyService/CompanyServiceMock.swift new file mode 100644 index 0000000..7411823 --- /dev/null +++ b/Projects/Core/NetworkKit/Sources/CompanyService/CompanyServiceMock.swift @@ -0,0 +1,34 @@ +// +// CompanyServiceMock.swift +// CommonKit +// +// Created by 김지수 on 10/10/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import Model + +//MARK: - Service +public final class CompanyServiceMock: CompanyServiceProtocol { + + public init() {} + + public func requestSearchCompany(keyword: String, next: String?) async throws -> ([Model.CompanySearchResponse], String?) { + return ([ + .init(id: "0", name: "현대글로비스"), + .init(id: "1", name: "현대자동차"), + .init(id: "2", name: "기아자동차"), + .init(id: "3", name: "채널톡"), + .init(id: "4", name: "닥터다이어리"), + .init(id: "5", name: "컬쳐커넥션"), + .init(id: "6", name: "엄청좋은회사"), + .init(id: "7", name: "로지텍"), + .init(id: "8", name: "스탠리"), + .init(id: "9", name: "스타벅스"), + .init(id: "10", name: "호날두주식회사"), + .init(id: "11", name: "애플"), + .init(id: "12", name: "엔비디아"), + ], nil) + } +} diff --git a/Projects/Core/NetworkKit/Sources/NetworkCore/Middleware/LogMiddleWare.swift b/Projects/Core/NetworkKit/Sources/NetworkCore/Middleware/LogMiddleWare.swift index 9b0ff97..17175b5 100644 --- a/Projects/Core/NetworkKit/Sources/NetworkCore/Middleware/LogMiddleWare.swift +++ b/Projects/Core/NetworkKit/Sources/NetworkCore/Middleware/LogMiddleWare.swift @@ -20,7 +20,7 @@ actor LoggingMiddleware { private let logger: Logger package let bodyLoggingPolicy: BodyLoggingPolicy - package init(logger: Logger = defaultLogger, bodyLoggingConfiguration: BodyLoggingPolicy = .upTo(maxBytes: 2048)) { + package init(logger: Logger = defaultLogger, bodyLoggingConfiguration: BodyLoggingPolicy = .upTo(maxBytes: 10000)) { self.logger = logger self.bodyLoggingPolicy = bodyLoggingConfiguration } @@ -77,19 +77,17 @@ extension LoggingMiddleware: ServerMiddleware { extension LoggingMiddleware { func log(_ request: HTTPRequest, _ requestBody: BodyLoggingPolicy.BodyLog) { - logger.debug( - "Request: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) body: \(requestBody, privacy: .auto)" - ) + let decodedPath = request.path?.removingPercentEncoding ?? "" + print("Request: \(request.method) \(decodedPath) body: \(requestBody)") } func log(_ request: HTTPRequest, _ response: HTTPResponse, _ responseBody: BodyLoggingPolicy.BodyLog) { - logger.debug( - "Response: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) \(response.status, privacy: .public) body: \(responseBody, privacy: .auto)" - ) + let decodedPath = request.path?.removingPercentEncoding ?? "" + print("Response: \(request.method) \(decodedPath) \(response.status) body: \(responseBody)") } func log(_ request: HTTPRequest, failedWith error: any Error) { - logger.warning("Request failed. Error: \(error.localizedDescription)") + print("Request failed. Error: \(error.localizedDescription)") } } diff --git a/Projects/DesignSystem/DesignCore/Sources/CTAButton/CTAButton.swift b/Projects/DesignSystem/DesignCore/Sources/CTAButton/CTAButton.swift index a7a2e0a..bb8e8ad 100644 --- a/Projects/DesignSystem/DesignCore/Sources/CTAButton/CTAButton.swift +++ b/Projects/DesignSystem/DesignCore/Sources/CTAButton/CTAButton.swift @@ -11,17 +11,19 @@ import SwiftUI public struct CTAButton: View { private let title: String private let backgroundStyle: BackgroundStyle - private let titleColor: Color = .white + private let titleColor: Color private let isActive: Bool private var handler: () -> Void public init( title: String, + titleColor: Color = .white, backgroundStyle: BackgroundStyle = DesignCore.Colors.grey500, isActive: Bool = true, handler: @escaping () -> Void ) { self.title = title + self.titleColor = titleColor self.backgroundStyle = backgroundStyle self.isActive = isActive self.handler = handler diff --git a/Projects/DesignSystem/DesignCore/Sources/DropDown/DropDownView.swift b/Projects/DesignSystem/DesignCore/Sources/DropDown/DropDownView.swift new file mode 100644 index 0000000..b7d3b6b --- /dev/null +++ b/Projects/DesignSystem/DesignCore/Sources/DropDown/DropDownView.swift @@ -0,0 +1,123 @@ +// +// DropDownView.swift +// DesignCore +// +// Created by 김지수 on 10/9/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI + +public protocol DropDownFetchable: Hashable, Equatable { + var id: String { get } + var name: String { get } +} + +public struct DropDownPicker: View { + + @FocusState var showDropDown: Bool + + var tapHandler: ((Int) -> Void)? + var content: () -> Content + + var dataSources: [any DropDownFetchable] + let itemSize: CGFloat = 56 + + var nextPageHandler: (() -> Void)? + var needCallNextPage = false + + var frameHeight: CGFloat { + if !showDropDown { + return 0 + } + + if dataSources.count > 3 { + return itemSize * 3 + itemSize * 0.5 + } + + return itemSize * CGFloat(dataSources.count) + } + + var frameOffset: CGFloat { + if !showDropDown { + return 0 + } + + if dataSources.count > 3 { + return 140 + } + + return 125 - 28 * CGFloat(3 - dataSources.count) + } + + public init( + dataSources: [any DropDownFetchable], + showDropDown: FocusState, + needCallNextPage: Bool = false, + @ViewBuilder content: @escaping () -> Content, + tapHandler: ((Int) -> Void)? = nil, + nextPageHandler: (() -> Void)? = nil + ) { + self.dataSources = dataSources + self.content = content + self.tapHandler = tapHandler + self._showDropDown = showDropDown + self.nextPageHandler = nextPageHandler + self.needCallNextPage = needCallNextPage + } + + public var body: some View { + VStack { + ZStack { + ZStack { + RoundedRectangle(cornerRadius: 24) + .foregroundStyle(.white) // + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(0 ..< dataSources.count, id: \.self) { index in + let item = dataSources[index] + Button(action: { + tapHandler?(index) + withAnimation { + showDropDown.toggle() + } + }, label: { + HStack(spacing: 16) { + Text(item.name) + .typography(.regular_14) + .multilineTextAlignment(.leading) + Spacer() + } + .foregroundStyle(DesignCore.Colors.grey500) + .frame(height: itemSize) + .padding(.horizontal, 16) + .background(.white) + }) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if needCallNextPage { + ProgressView() + .padding(.bottom, 10) + .onAppear { + nextPageHandler?() + } + } + } + } + .clipShape( + RoundedRectangle(cornerRadius: 24) + + ) + .shadow(.default) + .animation(.easeInOut(duration: 0.2), value: frameOffset) + .frame(height: frameHeight) + .offset(y: frameOffset) + + content() + } + .frame(height: 50) + } + .zIndex(999) + } +} diff --git a/Projects/DesignSystem/DesignCore/Sources/TextInput/TextInputView.swift b/Projects/DesignSystem/DesignCore/Sources/TextInput/TextInputView.swift index 0695e4b..eeff767 100644 --- a/Projects/DesignSystem/DesignCore/Sources/TextInput/TextInputView.swift +++ b/Projects/DesignSystem/DesignCore/Sources/TextInput/TextInputView.swift @@ -15,6 +15,7 @@ public struct TextInput: View where Content: View { @ViewBuilder let leftView: () -> Content @Binding var text: String @FocusState var isFocused: Bool + let isEnabled: Bool var rightIcon: TextInputRightIconModel? @@ -24,6 +25,7 @@ public struct TextInput: View where Content: View { text: Binding, keyboardType: UIKeyboardType = .default, isFocused: FocusState = .init(), + isEnabled: Bool = true, @ViewBuilder leftView: @escaping () -> Content = { EmptyView() }, rightIcon: TextInputRightIconModel? = nil ) { @@ -34,6 +36,7 @@ public struct TextInput: View where Content: View { self._isFocused = isFocused self.leftView = leftView self.rightIcon = rightIcon + self.isEnabled = isEnabled } public var body: some View { @@ -44,7 +47,7 @@ public struct TextInput: View where Content: View { DesignCore.Colors.grey100, lineWidth: isFocused ? 1.0 : 0 ) - .background(backgroundColor) + .background(isEnabled ? backgroundColor : DesignCore.Colors.grey100) HStack(spacing: 10) { leftView() @@ -57,6 +60,7 @@ public struct TextInput: View where Content: View { .tint(DesignCore.Colors.grey500) .foregroundStyle(DesignCore.Colors.grey500) .typography(.medium_16) + .disabled(!isEnabled) if let rightIcon { ZStack { diff --git a/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneInput/AuthPhoneInputView.swift b/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneInput/AuthPhoneInputView.swift index 1222edd..55c6ec6 100644 --- a/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneInput/AuthPhoneInputView.swift +++ b/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneInput/AuthPhoneInputView.swift @@ -49,6 +49,27 @@ public struct AuthPhoneInputView: View { Spacer() + // only dev 인증 건너뛰기 + #if STAGING || DEBUG + HStack { + Spacer() + Button("개발자 권력으로 건너뛰기") { + // pushNextView 열어주지 않고 dev에서만 캐스팅하여 사용 + if let intent = intent as? AuthPhoneInputIntent { + intent.pushNextView( + smsResponse: .init( + userType: .NEW, + authCodeId: "DEV-CODE", + phoneNumber: "010-1234-5678" + ) + ) + } + } + Spacer() + } + #endif + // -- + CTABottomButton( title: "다음", isActive: state.isPhoneValidated diff --git a/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneVerify/AuthPhoneVerifyView.swift b/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneVerify/AuthPhoneVerifyView.swift index 6be3bf1..8999400 100644 --- a/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneVerify/AuthPhoneVerifyView.swift +++ b/Projects/Features/SignUp/Sources/AuthSignUp/AuthPhoneVerify/AuthPhoneVerifyView.swift @@ -74,6 +74,21 @@ public struct AuthPhoneVerifyView: View { ) .tint(DesignCore.Colors.grey300) + // only dev 인증 건너뛰기 + #if STAGING || DEBUG + HStack { + Spacer() + Button("개발자 권력으로 건너뛰기") { + // pushNextView 열어주지 않고 dev에서만 캐스팅하여 사용 + if let intent = intent as? AuthPhoneVerifyIntent { + let targetPath = intent.getNextPath(userType: .NEW) + intent.pushNextView(to: targetPath) + } + } + Spacer() + } + #endif + Spacer() } .onChange(of: state.verifyCode) { diff --git a/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyIntent.swift b/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyIntent.swift new file mode 100644 index 0000000..41bf109 --- /dev/null +++ b/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyIntent.swift @@ -0,0 +1,153 @@ +// +// AuthCompanyIntent.swift +// DesignPreview +// +// Created by 김지수 on 10/9/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI +import CommonKit +import Combine +import CoreKit +import Model +import NetworkKit + +//MARK: - Intent +class AuthCompanyIntent { + private weak var model: AuthCompanyModelActionable? + private let input: DataModel + private let companyService: CompanyServiceProtocol + + private var cancellables = Set() + + // MARK: Life cycle + init( + model: AuthCompanyModelActionable, + input: DataModel, + companyService: CompanyServiceProtocol = CompanyService.shared + ) { + self.input = input + self.model = model + self.companyService = companyService + } +} + +//MARK: - Intentable +extension AuthCompanyIntent { + protocol Intentable { + // content + func onTextChanged(text: String) + func onCompanySelected(company: CompanySearchResponse) + func onTapNoCompanyToggle() + func onTapNextButton() + func onChangedFocusState(_ value: Bool) + func onTapSameCompanyMatching(isAgree: Bool) + func needRequestNextPage( + keyword: String, + next: String + ) + + // default + func onAppear() + func task() async + } + + struct DataModel {} +} + +//MARK: - Intentable +extension AuthCompanyIntent: AuthCompanyIntent.Intentable { + // default + func onAppear() { + if let model = model as? AuthCompanyModel { + model.$textInput + .removeDuplicates() + .debounce( + for: .seconds(0.75), + scheduler: RunLoop.main + ) + .sink { [weak self] text in + Task { [weak self] in + await self?.searchCompanyData( + keyword: text + ) + } + } + .store(in: &cancellables) + } + } + + func task() async {} + + // content + func onChangedFocusState(_ value: Bool) { + model?.setFocusState(value) + } + func onTapNoCompanyToggle() { + model?.setToggleNoCompany() + } + func onTextChanged(text: String) { + if let model = model as? AuthCompanyModel, + model.selectedCompany?.name != text { + model.setSelectedCompany(nil) + } + } + func onCompanySelected(company: CompanySearchResponse) { + model?.setSelectedCompany(company) + } + func searchCompanyData(keyword: String) async { + guard keyword.count > 0 else { + model?.setResponseData([]) + model?.setSelectedCompany(nil) + return + } + await requestCompanyList(keyword: keyword, needAppend: false) + } + func onTapSameCompanyMatching(isAgree: Bool) { + model?.setSameCompanyMatchingAvailable(isAgree) + } + func needRequestNextPage( + keyword: String, + next: String + ) { + Task { + await requestCompanyList( + keyword: keyword, + next: next, + needAppend: true + ) + } + } + // company list API 요청 + func requestCompanyList( + keyword: String, + next: String? = nil, + needAppend: Bool + ) async { + do { + let (response, next) = try await companyService.requestSearchCompany( + keyword: keyword, + next: next + ) + if needAppend { + model?.appendResponseData(response) + } else { + model?.setResponseData(response) + } + model?.setNextPaginationKey(next) + } catch { + print(error) + } + } + func onTapNextButton() { + Task { + await pushNextView() + } + } + + @MainActor + func pushNextView() { + AppCoordinator.shared.push(.signUp(.authName)) + } +} diff --git a/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyModel.swift b/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyModel.swift new file mode 100644 index 0000000..667337c --- /dev/null +++ b/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyModel.swift @@ -0,0 +1,157 @@ +// +// AuthCompanyModel.swift +// DesignPreview +// +// Created by 김지수 on 10/9/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import CommonKit +import CoreKit +import Model +import DesignCore + +extension CompanySearchResponse: DropDownFetchable { + public static func == (lhs: CompanySearchResponse, rhs: CompanySearchResponse) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(0) + } +} + +final class AuthCompanyModel: ObservableObject { + + //MARK: Stateful + protocol Stateful { + // content + var searchResponse: [CompanySearchResponse] { get } + var nextSearchKey: String? { get } + var needPagination: Bool { get } + var selectedCompany: CompanySearchResponse? { get } + var isNoCompanyHere: Bool { get } + var textInput: String { get set } + var isValidated: Bool { get } + var isTextFieldFocused: Bool { get } + var isTextFieldEnabled: Bool { get } + var sameCompanyMatchingAvailable: Bool? { get } + + // default + var isLoading: Bool { get } + + // error + var showErrorView: ErrorModel? { get } + var showErrorAlert: ErrorModel? { get } + } + + //MARK: State Properties + // content + @Published var searchResponse: [CompanySearchResponse] = [] + var nextSearchKey: String? = nil + @Published var selectedCompany: CompanySearchResponse? = nil + @Published var textInput: String = "" + @Published var isNoCompanyHere: Bool = false + @Published var isTextFieldFocused: Bool = false + @Published var isTextFieldEnabled: Bool = true + @Published var sameCompanyMatchingAvailable: Bool? + + var needPagination: Bool { + return nextSearchKey != nil + } + + var isValidated: Bool { + if isNoCompanyHere { + return true + } else { + return selectedCompany != nil + } + } + + // default + @Published var isLoading: Bool = false + + // error + @Published var showErrorView: ErrorModel? + @Published var showErrorAlert: ErrorModel? +} + +extension AuthCompanyModel: AuthCompanyModel.Stateful {} + +//MARK: - Actionable +protocol AuthCompanyModelActionable: AnyObject { + // content + func setResponseData(_ response: [CompanySearchResponse]) + func appendResponseData(_ response: [CompanySearchResponse]) + func setNextPaginationKey(_ next: String?) + func setSelectedCompany(_ company: CompanySearchResponse?) + func setToggleNoCompany() + func setValidation(value: Bool) + func setFocusState(_ value: Bool) + func setSameCompanyMatchingAvailable(_ value: Bool) + + // default + func setLoading(status: Bool) + + // error + func showErrorView(error: ErrorModel) + func showErrorAlert(error: ErrorModel) + func resetError() +} + +extension AuthCompanyModel: AuthCompanyModelActionable { + // content + func setResponseData(_ response: [CompanySearchResponse]) { + searchResponse = response + } + func appendResponseData(_ response: [CompanySearchResponse]) { + searchResponse.append(contentsOf: response) + } + func setNextPaginationKey(_ next: String?) { + nextSearchKey = next + } + func setSelectedCompany(_ company: CompanySearchResponse?) { + selectedCompany = company + if let company { + textInput = company.name + } + } + func setSameCompanyMatchingAvailable(_ value: Bool) { + sameCompanyMatchingAvailable = value + } + func setFocusState(_ value: Bool) { + isTextFieldFocused = value + } + func setToggleNoCompany() { + isNoCompanyHere.toggle() + + // on 이 된다면 + if isNoCompanyHere { + selectedCompany = nil + textInput = "" + isTextFieldFocused = false + isTextFieldEnabled = false + } else { + isTextFieldEnabled = true + } + } + func setValidation(value: Bool) {} + + // default + func setLoading(status: Bool) { + isLoading = status + } + + // error + func showErrorView(error: ErrorModel) { + showErrorView = error + } + func showErrorAlert(error: ErrorModel) { + showErrorAlert = error + } + func resetError() { + showErrorView = nil + showErrorAlert = nil + } +} diff --git a/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyView.swift b/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyView.swift new file mode 100644 index 0000000..f103cd0 --- /dev/null +++ b/Projects/Features/SignUp/Sources/ProfileInput/AuthCompany/AuthCompanyView.swift @@ -0,0 +1,230 @@ +// +// AuthCompanyView.swift +// DesignPreview +// +// Created by 김지수 on 10/9/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI +import CoreKit +import DesignCore +import CommonKit + +public struct AuthCompanyView: View { + + @StateObject var container: MVIContainer + + private var intent: AuthCompanyIntent.Intentable { container.intent } + private var state: AuthCompanyModel.Stateful { container.model } + + @FocusState var showDropDown: Bool + @State var isShowSameCompanyPopup = false + + public init() { + let model = AuthCompanyModel() + let intent = AuthCompanyIntent( + model: model, + input: .init() + ) + let container = MVIContainer( + intent: intent as AuthCompanyIntent.Intentable, + model: model as AuthCompanyModel.Stateful, + modelChangePublisher: model.objectWillChange + ) + self._container = StateObject(wrappedValue: container) + } + + var bottomSpacingHeight: CGFloat { + return showDropDown ? Device.height * 0.7 : 0 + } + + var textInputRightIcon: TextInputRightIconModel { + if state.selectedCompany != nil { + return .init( + icon: DesignCore.Images.checkBold.image, + backgroundColor: Color(hex: 0x2DE76B) + ) + } else { + return .init( + icon: DesignCore.Images.search.image, + backgroundColor: .init(hex: 0xCAC7C5) + ) + } + } + + public var body: some View { + ZStack { + ScrollView { + ScrollViewReader { proxy in + VStack { + ProfileInputTemplatedView( + currentPage: 3, + maxPage: 5, + subMessage: "운명의 상대를 만나기 딱 좋은 나이네요.", + mainMessage: "당신은 지금 어떤 회사에서\n재직하고 있나요?" + ) { + DropDownPicker( + dataSources: state.searchResponse, + showDropDown: _showDropDown, + needCallNextPage: state.needPagination + ) { + TextInput( + placeholder: "내 회사 검색", + text: $container.model.textInput, + keyboardType: .namePhonePad, + isFocused: _showDropDown, + isEnabled: state.isTextFieldEnabled, + rightIcon: textInputRightIcon + ) + .interactiveDismissDisabled() + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .speechAnnouncementsQueued(false) + .speechSpellsOutCharacters(false) + } tapHandler: { index in + let company = state.searchResponse[index] + intent.onCompanySelected( + company: company + ) + } nextPageHandler: { + print("다음 페이지~") + if let nextSearchKey = state.nextSearchKey { + print("\(nextSearchKey)로 호출") + intent.needRequestNextPage( + keyword: state.textInput, + next: nextSearchKey + ) + } + } + .padding(.horizontal, 24) + .onChange(of: state.isTextFieldFocused) { + showDropDown = state.isTextFieldFocused + } + .onChange(of: showDropDown) { + intent.onChangedFocusState(showDropDown) + } + + HStack { + Spacer() + Image(systemName: state.isNoCompanyHere ? "checkmark.square.fill" : "square") + .resizable() + .frame(width: 14, height: 14) + .foregroundStyle(DesignCore.Colors.grey200) + Text("회사 검색 목록에 없어요.") + .typography(.medium_14) + .foregroundStyle(DesignCore.Colors.grey400) + Spacer() + } + .padding(.horizontal, 60) + .padding(.vertical, 14) + .background(DesignCore.Colors.grey100.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 24) + .contentShape(Rectangle()) + .padding(.top, 12) + .onTapGesture { + intent.onTapNoCompanyToggle() + } + } + .id(0) + + Spacer() + .frame(height: bottomSpacingHeight) + .id(1) + .onChange(of: showDropDown) { + if showDropDown { + withAnimation { + proxy.scrollTo(1) + } + } + } + .foregroundStyle(.red) + } + .onTapGesture { + withAnimation { + showDropDown = false + } + } + } + } + CTABottomButton( + title: "다음", + isActive: state.isValidated + ) { + /// 회사를 정확하게 파악할 수 있다면 -> 같은 회사 매칭 팝업 보여주기 + if !state.isNoCompanyHere { + isShowSameCompanyPopup = true + } else { + /// 회사 정확하게 파악 불가하다면 다음 뷰로 + intent.onTapNextButton() + } + } + } + .sheet(isPresented: $isShowSameCompanyPopup) { + sameCompanyMatchingPopUpView + .presentationDetents([.height(280)]) + .presentationCornerRadius(20) + } + .onChange(of: state.textInput) { + intent.onTextChanged(text: state.textInput) + } + .task { + await intent.task() + } + .onAppear { + intent.onAppear() + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .textureBackground() + .setPopNavigation { + AppCoordinator.shared.pop() + } + .setLoading(state.isLoading) + } + + @ViewBuilder + var sameCompanyMatchingPopUpView: some View { + VStack { + VStack(spacing: 0) { + LeftAlignText("잠시만요!") + .typography(.regular_14) + .foregroundStyle(DesignCore.Colors.grey200) + LeftAlignText("같은 회사 사람도 소개 받을까요?") + .typography(.semibold_20) + .foregroundStyle(DesignCore.Colors.grey500) + } + + Spacer() + + VStack { + CTAButton( + title: "같은 회사는 소개받기 싫어요", + backgroundStyle: DesignCore.Colors.red300 + ) { + intent.onTapSameCompanyMatching(isAgree: false) + isShowSameCompanyPopup = false + intent.onTapNextButton() + } + + CTAButton( + title: "네, 받을래요", + titleColor: DesignCore.Colors.grey400, + backgroundStyle: DesignCore.Colors.grey50 + ) { + intent.onTapSameCompanyMatching(isAgree: true) + isShowSameCompanyPopup = false + intent.onTapNextButton() + } + } + } + .padding(.horizontal, 26) + .padding(.vertical, 30) + } +} + +#Preview { + NavigationView { + AuthCompanyView() + } +} diff --git a/Projects/Features/SignUp/Sources/ProfileInput/AuthProfileAge/AuthProfileAgeInputIntent.swift b/Projects/Features/SignUp/Sources/ProfileInput/AuthProfileAge/AuthProfileAgeInputIntent.swift index e4bc084..00bec39 100644 --- a/Projects/Features/SignUp/Sources/ProfileInput/AuthProfileAge/AuthProfileAgeInputIntent.swift +++ b/Projects/Features/SignUp/Sources/ProfileInput/AuthProfileAge/AuthProfileAgeInputIntent.swift @@ -69,7 +69,7 @@ extension AuthProfileAgeInputIntent: AuthProfileAgeInputIntent.Intentable { Task { // TODO: 순서 재정의 await AppCoordinator.shared.push( - .signUp(.authName) + .signUp(.authCompany) ) } } diff --git a/Projects/Features/SignUp/UnitTest/AuthCompanyTest.swift b/Projects/Features/SignUp/UnitTest/AuthCompanyTest.swift new file mode 100644 index 0000000..62fb380 --- /dev/null +++ b/Projects/Features/SignUp/UnitTest/AuthCompanyTest.swift @@ -0,0 +1,91 @@ +// +// AuthCompanyTest.swift +// SignUp-UnitTest +// +// Created by 김지수 on 10/10/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import XCTest +@testable import SignUp +import Model +import NetworkKit +import CommonKit + +class AuthCompanyTest: XCTestCase { + var state: AuthCompanyModel! + var intent: AuthCompanyIntent! + + override func setUp() { + super.setUp() + state = AuthCompanyModel() + intent = AuthCompanyIntent( + model: state, + input: .init(), + companyService: CompanyServiceMock() + ) + intent.onAppear() + } + + override func tearDown() { + state = nil + intent = nil + super.tearDown() + } + + func testCompanySelectionFlow() async { + // 검색어가 없을 때는 request 하지 않음 + state.textInput = "" + try? await Task.sleep(for: .seconds(1)) + XCTAssertEqual( + state.searchResponse, + [] + ) + + state.textInput = "검색어" + try? await Task.sleep(for: .seconds(1)) + + // 선택 + intent.onCompanySelected( + company: state.searchResponse[0] + ) + // 선택했을 때는 validated 여야 함 + XCTAssertTrue(state.isValidated) + XCTAssertEqual( + state.selectedCompany?.name, + state.textInput + ) + // 다른 검색어가 들어왔을 때는 selection, validation 초기화 + state.textInput = "다른검색어" + intent.onTextChanged(text: "다른검색어") + XCTAssertEqual( + state.isValidated, + false + ) + XCTAssertNil(state.selectedCompany) + } + + func testNotExistMyCompanyToggle() async { + state.textInput = "검색어" + + try? await Task.sleep(for: .seconds(1)) + // 선택 + intent.onCompanySelected( + company: state.searchResponse[0] + ) + // 여기 내 회사 없어요 체크 + intent.onTapNoCompanyToggle() + XCTAssertEqual( + state.isTextFieldFocused, + false + ) + XCTAssertEqual( + state.isTextFieldEnabled, + false + ) + XCTAssertTrue(state.isValidated) + // 선택했던 회사는 해제되어야 함 + XCTAssertNil(state.selectedCompany) + + } +}