diff --git a/.gitignore b/.gitignore index 1b3f42ee..02ba8c34 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,4 @@ fastlane/*.env # App Packaging *.ipa *.dSYM.zip -*.dSYM \ No newline at end of file +*.dSYM diff --git a/Plugins/THTIOSHappyNewYear/ProjectDescriptionHelpers/InfoPlist.swift b/Plugins/THTIOSHappyNewYear/ProjectDescriptionHelpers/InfoPlist.swift index 507567ae..9ec2feb3 100644 --- a/Plugins/THTIOSHappyNewYear/ProjectDescriptionHelpers/InfoPlist.swift +++ b/Plugins/THTIOSHappyNewYear/ProjectDescriptionHelpers/InfoPlist.swift @@ -12,6 +12,36 @@ public let infoPlistExtension: [String: InfoPlist.Value] = [ "CFBundleVersion": "1", "UILaunchStoryboardName": "LaunchScreen", "CFBundleName": "THT", + "UIApplicationSceneManifest": [ + "UIApplicationSupportsMultipleScenes": false, + "UISceneConfigurations": [ + "UIWindowSceneSessionRoleApplication": [ + [ + "UISceneConfigurationName": "Default Configuration", + "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate" + ], + ] + ] + ], + + // MARK: Privacy + + "UIAppFonts": [ + "Item 0": "Pretendard-Medium.otf", + "Item 1": "Pretendard-Regular.otf", + "Item 2": "Pretendard-SemiBold.otf", + "Item 3": "Pretendard-Bold.otf", + "Item 4": "Pretendard-ExtraBold.otf" + ], + "UIUserInterfaceStyle": "Dark" +] + +public func infoPlistExtension(name: String) -> [String: InfoPlist.Value] { + [ + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "UILaunchStoryboardName": "LaunchScreen", + "CFBundleName": "\(name)", "UIApplicationSceneManifest": [ "UIApplicationSupportsMultipleScenes": false, "UISceneConfigurations": [ @@ -23,8 +53,15 @@ public let infoPlistExtension: [String: InfoPlist.Value] = [ ] ] ], - "App Transport Security Settings": ["Allow Arbitrary Loads": true], - "Privacy - Photo Library Additions Usage Description": "프로필에 사용됨", + "NSAppTransportSecurity": ["NSAllowsArbitraryLoads": true], + + // MARK: Privacy + "NSContactsUsageDescription": "연락처 사용", + "NSLocationWhenInUseUsageDescription": "위치 정보 사용", + + // MARK: 수출 규청 알고리즘 통과 + "ITSAppUsesNonExemptEncryption": false, + "UIAppFonts": [ "Item 0": "Pretendard-Medium.otf", "Item 1": "Pretendard-Regular.otf", @@ -33,35 +70,5 @@ public let infoPlistExtension: [String: InfoPlist.Value] = [ "Item 4": "Pretendard-ExtraBold.otf" ], "UIUserInterfaceStyle": "Dark" -] - -public func infoPlistExtension(name: String) -> [String: InfoPlist.Value] { - [ - "CFBundleShortVersionString": "1.0", - "CFBundleVersion": "1", - "UILaunchStoryboardName": "LaunchScreen", - "CFBundleName": "\(name)", - "UIApplicationSceneManifest": [ - "UIApplicationSupportsMultipleScenes": false, - "UISceneConfigurations": [ - "UIWindowSceneSessionRoleApplication": [ - [ - "UISceneConfigurationName": "Default Configuration", - "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate" - ], - ] - ] - ], - "App Transport Security Settings": ["Allow Arbitrary Loads": true], - "Privacy - Photo Library Additions Usage Description": "프로필에 사용됨", - "UIAppFonts": [ - "Item 0": "Pretendard-Medium.otf", - "Item 1": "Pretendard-Regular.otf", - "Item 2": "Pretendard-SemiBold.otf", - "Item 3": "Pretendard-Bold.otf", - "Item 4": "Pretendard-ExtraBold.otf" - ], - "UIUserInterfaceStyle": "Dark", - "ITSAppUsesNonExemptEncryption": false // 수출 규정 누락 문제 ] } diff --git a/Projects/App/Src/SceneDelegate+Register.swift b/Projects/App/Src/SceneDelegate+Register.swift index 64a40fa7..9003c88c 100644 --- a/Projects/App/Src/SceneDelegate+Register.swift +++ b/Projects/App/Src/SceneDelegate+Register.swift @@ -12,10 +12,6 @@ import Data import Feature import Networks -import FallingInterface -import LikeInterface -import ChatInterface -import MyPageInterface extension AppDelegate { var container: DIContainer { @@ -23,6 +19,27 @@ extension AppDelegate { } func registerDependencies() { + let tokenStore = UserDefaultTokenStore() + let tokenProvider = DefaultTokenProvider() + + container.register( + interface: UserInfoUseCaseInterface.self, + implement: { UserInfoUseCase(repository: UserDefaultUserInfoRepository()) }) + + container.register( + interface: AuthUseCaseInterface.self, + implement: { AuthUseCase(authRepository: AuthRepository(tokenStore: tokenStore, tokenProvider: tokenProvider), + tokenStore: tokenStore) }) + container.register( + interface: SignUpUseCaseInterface.self, + implement: { SignUpUseCase( + repository: SignUpRepository(), + locationService: LocationService(), + kakaoAPIService: KakaoAPIService(), + contactService: ContactService(), + tokenStore: tokenStore) + }) + container.register( interface: FallingUseCaseInterface.self, implement: { diff --git a/Projects/Core/Src/BaseCoordinator/LaunchCoordinator.swift b/Projects/Core/Src/BaseCoordinator/LaunchCoordinator.swift index 1b51bf0e..1d7571b8 100644 --- a/Projects/Core/Src/BaseCoordinator/LaunchCoordinator.swift +++ b/Projects/Core/Src/BaseCoordinator/LaunchCoordinator.swift @@ -7,7 +7,7 @@ import UIKit -public protocol LaunchCoordinating { +public protocol LaunchCoordinating: Coordinator { func launch(window: UIWindow) } @@ -21,10 +21,6 @@ open class LaunchCoordinator: BaseCoordinator, LaunchCoordinating { window.rootViewController = self.viewControllable.uiController window.makeKeyAndVisible() - TFLogger.domain.debug("AppCoordinator 1초 async") - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.start() - } + self.start() } } - diff --git a/Projects/Core/Src/BaseType/ViewControllable.swift b/Projects/Core/Src/BaseType/ViewControllable.swift index d39bc61f..84e96f99 100644 --- a/Projects/Core/Src/BaseType/ViewControllable.swift +++ b/Projects/Core/Src/BaseType/ViewControllable.swift @@ -39,4 +39,50 @@ public extension ViewControllable { self.uiController.navigationController?.popViewController(animated: animated) } } + + func present(_ viewControllable: ViewControllable, animated: Bool) { + if let nav = self.uiController as? UINavigationController { + nav.present(viewControllable.uiController, animated: animated) + } else { + self.uiController.navigationController?.present(viewControllable.uiController, animated: animated) + } + } + + func dismiss() { + if let presented = self.uiController.presentedViewController { + presented.dismiss(animated: true) + } else { + self.uiController.dismiss(animated: true) + } + } + + func presentBottomSheet(_ viewControllable: ViewControllable, animated: Bool) { + let navigation = NavigationViewControllable(rootViewControllable: viewControllable) + if let sheet = navigation.uiController.sheetPresentationController { + sheet.prefersGrabberVisible = false + sheet.preferredCornerRadius = 12 + sheet.detents = [ + .small() + ] + } + + if let nav = self.uiController as? UINavigationController { + nav.present(navigation.uiController, animated: animated) + } else { + self.uiController.navigationController?.present(navigation.uiController, animated: animated) + } + } +} + +extension UISheetPresentationController.Detent { + static func small( + identifier: UISheetPresentationController.Detent.Identifier? = nil, + resolvedValue: CGFloat = 300 + ) -> UISheetPresentationController.Detent { + return .custom { context in + resolvedValue + } + } + +// static let small = UISheetPresentationController.Detent.Identifier("small") } diff --git a/Projects/Core/Src/Util/AppData.swift b/Projects/Core/Src/Util/AppData.swift new file mode 100644 index 00000000..84358d9a --- /dev/null +++ b/Projects/Core/Src/Util/AppData.swift @@ -0,0 +1,32 @@ +// +// AppData.swift +// Core +// +// Created by Kanghos on 2024/03/02. +// + +import Foundation + +public struct AppData { + private enum Key: String { + case accessToken + case phoneNumber + case accessTokenExpiredIn + } + public struct Auth { + @Storage(key: Key.accessToken.rawValue, defaultValue: "") + public static var accessToken + + @Storage(key: Key.accessTokenExpiredIn.rawValue, defaultValue: 0) + public static var accessTokenExpiredIn + + public static var needAuth: Bool { + accessToken.isEmpty + } + } + + public struct User { + @Storage(key: Key.phoneNumber.rawValue, defaultValue: "") + public static var phoneNumber + } +} diff --git a/Projects/Core/Src/Util/DateFormatter+Util.swift b/Projects/Core/Src/Util/DateFormatter+Util.swift index d0d86228..a23398ef 100644 --- a/Projects/Core/Src/Util/DateFormatter+Util.swift +++ b/Projects/Core/Src/Util/DateFormatter+Util.swift @@ -25,11 +25,19 @@ extension DateFormatter { // formatter.locale = Locale(identifier: "ko-KR") return formatter } + + static var normalDateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + formatter.locale = Locale(identifier: "ko-KR") + + return formatter + } } public extension String { func toDate() -> Date { - DateFormatter.unixDateFormatter.date(from: self) ?? Date() + DateFormatter.normalDateFormatter.date(from: self) ?? Date() } } @@ -37,4 +45,31 @@ public extension Date { func toTimeString() -> String { DateFormatter.timeFormatter.string(from: self) } + func toDateString() -> String { + DateFormatter.unixDateFormatter.string(from: self) + } + func toYMDDotDateString() -> String { + DateFormatter.normalDateFormatter.string(from: self) + } + + // From GPT + static func currentAdultDateOrNil() -> Date? { + // 성년이 되는 나이 + let adulthoodAge = 21 + + // 현재 달력 + var calendar = Calendar.current + + // 지역을 한국으로 설정 (옵션) + calendar.locale = Locale(identifier: "ko_KR") + + // 날짜 구성 요소를 추출 + var dateComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + // 성년 나이를 더함 + dateComponents.year = (dateComponents.year ?? 0) - adulthoodAge + + // 새로운 날짜를 생성하여 반환 + return calendar.date(from: dateComponents) + } } diff --git a/Projects/Core/Src/Util/JSONStorage.swift b/Projects/Core/Src/Util/JSONStorage.swift new file mode 100644 index 00000000..91c70d5c --- /dev/null +++ b/Projects/Core/Src/Util/JSONStorage.swift @@ -0,0 +1,57 @@ +// +// JSONStorage.swift +// Core +// +// Created by Kanghos on 5/14/24. +// + +import Foundation + +extension UserDefaults { + + public func setCodableObject( + _ object: Object, forKey: String + ) throws where Object: Encodable { + + let data = try JSONEncoder().encode(object) + self.set(data, forKey: forKey) + } + + public func getCodableObject( + forKey: String, + as type: Object.Type + ) throws -> Object where Object: Decodable { + + guard let data = self.data(forKey: forKey) else { + throw NSError(domain: "UserDefaults", code: 0, userInfo: nil) + } + return try JSONDecoder().decode(type, from: data) + } +} + +@propertyWrapper +public struct CodableStorage { + private let key: String + private let defaultValue: T? + + public init(key: String, defaultValue: T? = nil) { + self.key = key + self.defaultValue = defaultValue + } + + public var wrappedValue: T? { + get { + guard let object = try? UserDefaults.standard.getCodableObject(forKey: key, as: T.self) else { + return defaultValue + } + return object + } + set { + if newValue == nil { + UserDefaults.standard.removeObject(forKey: key) + } + try? UserDefaults.standard.setCodableObject(newValue, forKey: key) + } + } +} + diff --git a/Projects/Core/Src/Util/Storage.swift b/Projects/Core/Src/Util/Storage.swift new file mode 100644 index 00000000..d7e00267 --- /dev/null +++ b/Projects/Core/Src/Util/Storage.swift @@ -0,0 +1,28 @@ +// +// Storage.swift +// Core +// +// Created by Kanghos on 2024/03/02. +// + +import Foundation + +@propertyWrapper +public struct Storage { + private let key: String + private let defaultValue: T + + public init(key: String, defaultValue: T) { + self.key = key + self.defaultValue = defaultValue + } + + public var wrappedValue: T { + get { + return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue + } + set { + UserDefaults.standard.setValue(newValue, forKey: key) + } + } +} diff --git a/Projects/Data/Src/Base/OAuthCredential.swift b/Projects/Data/Src/Base/OAuthCredential.swift new file mode 100644 index 00000000..2c246236 --- /dev/null +++ b/Projects/Data/Src/Base/OAuthCredential.swift @@ -0,0 +1,29 @@ +// +// OAuthCredential.swift +// Data +// +// Created by Kanghos on 6/6/24. +// + +import Foundation +import AuthInterface + +import Alamofire + +struct OAuthCredential: AuthenticationCredential { + let accessToken: String + let accessTokenExpiresIn: Double + var requiresRefresh: Bool { Date().timeIntervalSince1970 - 60 * 5 > accessTokenExpiresIn } +} + +extension Token { + func toAuthOCredential() -> OAuthCredential { + OAuthCredential(accessToken: accessToken, accessTokenExpiresIn: accessTokenExpiresIn) + } +} + +extension OAuthCredential { + func toToken() -> Token { + Token(accessToken: accessToken, accessTokenExpiresIn: accessTokenExpiresIn) + } +} diff --git a/Projects/Data/Src/Base/TFIntercepter.swift b/Projects/Data/Src/Base/TFIntercepter.swift new file mode 100644 index 00000000..e104c1b1 --- /dev/null +++ b/Projects/Data/Src/Base/TFIntercepter.swift @@ -0,0 +1,69 @@ +// +// TFIntercepter.swift +// Data +// +// Created by Kanghos on 6/5/24. +// + +import Foundation +import Moya +import Alamofire +import RxSwift +import AuthInterface + +final class OAuthAuthenticator: Authenticator { + private let tokenProvider: TokenProvider + + init(tokenProvider: TokenProvider) { + self.tokenProvider = tokenProvider + } + + func apply(_ credential: OAuthCredential, to urlRequest: inout URLRequest) { + + // SignUp 관련 API는 토큰 없이 호출해야함 + if let url = urlRequest.url, url.path().contains("users/join") == false { + urlRequest.headers.add(.authorization(bearerToken: credential.accessToken)) + } + } + + func refresh(_ credential: OAuthCredential, + for session: Session, + completion: @escaping (Result) -> Void) { + + + // Refresh the credential using the refresh token...then call completion with the new credential. + // + // The new credential will automatically be stored within the `AuthenticationInterceptor`. Future requests will + // be authenticated using the `apply(_:to:)` method using the new credential. + + tokenProvider.refreshToken(token: credential.toToken()) { result in + switch result { + case .success(let token): + completion(.success(token.toAuthOCredential())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func didRequest(_ urlRequest: URLRequest, + with response: HTTPURLResponse, + failDueToAuthenticationError error: Error) -> Bool { + // If authentication server CANNOT invalidate credentials, return `false` + + // If authentication server CAN invalidate credentials, then inspect the response matching against what the + // authentication server returns as an authentication failure. This is generally a 401 along with a custom + // header value. + // return response.statusCode == 401 + return response.statusCode == 401 + } + + func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: OAuthCredential) -> Bool { + // If authentication server CANNOT invalidate credentials, return `true` + + // If authentication server CAN invalidate credentials, then compare the "Authorization" header value in the + // `URLRequest` against the Bearer token generated with the access token of the `Credential`. + let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value + return urlRequest.headers["Authorization"] == bearerToken + } +} diff --git a/Projects/Data/Src/Model/Request/Auth/LoginReq.swift b/Projects/Data/Src/Model/Request/Auth/LoginReq.swift new file mode 100644 index 00000000..4e7951a8 --- /dev/null +++ b/Projects/Data/Src/Model/Request/Auth/LoginReq.swift @@ -0,0 +1,13 @@ +// +// LoginReq.swift +// Data +// +// Created by Kanghos on 6/3/24. +// + +import Foundation + +struct LoginReq: Codable { + let phoneNumber: String + let deviceKey: String +} diff --git a/Projects/Data/Src/Model/Response/Kakao/KakaoCoordinate2dRes.swift b/Projects/Data/Src/Model/Response/Kakao/KakaoCoordinate2dRes.swift new file mode 100644 index 00000000..229d6e0f --- /dev/null +++ b/Projects/Data/Src/Model/Response/Kakao/KakaoCoordinate2dRes.swift @@ -0,0 +1,71 @@ +// +// KakaoCoordinate2dRes.swift +// Data +// +// Created by Kanghos on 5/12/24. +// + +import Foundation +import SignUpInterface + +struct KakaoCoordinateRes: Codable { + let documents: [Document] + + struct Document: Codable { + let addressName: String + let region1depthName, region2depthName, region3depthName: String + let regionType: String + let code: String + let x, y: Double + + private enum CodingKeys: String, CodingKey { + case addressName = "address_name" + case region1depthName = "region_1depth_name" + case region2depthName = "region_2depth_name" + case region3depthName = "region_3depth_name" + case regionType = "region_type" + case code + case x, y + } + + enum RegionType: String, Codable { + case admin = "H" + case law = "B" + + private enum CodingKeys: String, CodingKey { + case admin = "H" + case law = "B" + } + } + } +} + +extension KakaoCoordinateRes { + func toDomain() -> LocationReq? { + let lawAddress = documents.first { docmuent in + docmuent.regionType == "B" + } + + guard let document = lawAddress else { return nil } + + + var cityName = document.region1depthName + if cityName.hasSuffix("특별시") { + cityName = cityName.replacingOccurrences(of: "특별시", with: "") + } else if cityName.hasSuffix("광역시") { + cityName = cityName.replacingOccurrences(of: "광역시", with: "") + } + + var dongName = document.region3depthName + while !dongName.isEmpty { + if dongName.hasSuffix("동") { + break + } + dongName.removeLast() + } + + let addressName = [cityName, document.region2depthName, dongName].joined(separator: " ") + + return .init(address: addressName, regionCode: Int(document.code) ?? 0, lat: document.y, lon: document.x) + } +} diff --git a/Projects/Data/Src/Model/Response/Kakao/KakaoSearchRes.swift b/Projects/Data/Src/Model/Response/Kakao/KakaoSearchRes.swift new file mode 100644 index 00000000..ff42e3fe --- /dev/null +++ b/Projects/Data/Src/Model/Response/Kakao/KakaoSearchRes.swift @@ -0,0 +1,71 @@ +// +// KakaoSearchRes.swift +// Data +// +// Created by Kanghos on 5/12/24. +// + +import Foundation + +import SignUpInterface + +struct KakaoSearchRes: Codable { + let documents: [Document] + + struct Document: Codable { + let address: Address? + let roadAddress: RoadAddress? + let x, y: String + + private enum CodingKeys: String, CodingKey { + case address, x, y + case roadAddress = "road_address" + } + } + + struct Address: Codable { + let addressName: String + let region1depthName, region2depthName, region3depthName: String + let lawCode: String + let x, y: String + + private enum CodingKeys: String, CodingKey { + case addressName = "address_name" + case region1depthName = "region_1depth_name" + case region2depthName = "region_2depth_name" + case region3depthName = "region_3depth_name" + case lawCode = "b_code" + case x, y + } + } + + struct RoadAddress: Codable { + let addressName: String + let region1depthName, region2depthName, region3depthName: String + let x,y: String + + private enum CodingKeys: String, CodingKey { + case addressName = "address_name" + case region1depthName = "region_1depth_name" + case region2depthName = "region_2depth_name" + case region3depthName = "region_3depth_name" + case x, y + } + } +} + +extension KakaoSearchRes { + func toDomain() -> LocationReq? { + guard let document = documents.first, + let address = document.address, + let longitude = Double(document.x), + let latitude = Double(document.y) else { + return nil + } + + let addressName = [address.region1depthName, address.region2depthName, address.region3depthName].joined(separator: " ") + + let code = Int(address.lawCode) ?? 0 + return .init(address: addressName, regionCode: code, lat: latitude, lon: longitude) + } +} diff --git a/Projects/Data/Src/Repository/Auth/AuthRepository.swift b/Projects/Data/Src/Repository/Auth/AuthRepository.swift new file mode 100644 index 00000000..8af393aa --- /dev/null +++ b/Projects/Data/Src/Repository/Auth/AuthRepository.swift @@ -0,0 +1,68 @@ +// +// AuthRepository.swift +// Data +// +// Created by Kanghos on 6/3/24. +// + +import Foundation + +import AuthInterface +import SignUpInterface +import Networks + +import RxSwift +import RxMoya +import Moya +import Alamofire + +public final class AuthRepository: ProviderProtocol { + + public typealias Target = AuthTarget + public var provider: MoyaProvider + private let tokenStore: TokenStore + private let tokenProvider: TokenProvider + + public init(tokenStore: TokenStore, tokenProvider: TokenProvider) { + self.tokenStore = tokenStore + self.tokenProvider = tokenProvider + + let token = (try? tokenStore.getToken()) ?? Token(accessToken: "", accessTokenExpiresIn: 0) + let credential = token.toAuthOCredential() + + let authenticator = OAuthAuthenticator(tokenProvider: tokenProvider) + let intercepter = AuthenticationInterceptor(authenticator: authenticator, credential: credential) + let session = Session(interceptor: intercepter) + + self.provider = MoyaProvider(session: session) + } +} + +extension AuthRepository: AuthRepositoryInterface { + public func checkUserExist(phoneNumber: String) -> RxSwift.Single { + request(type: UserSignUpInfoRes.self, target: .checkExistence(phoneNumber: phoneNumber)) + } + + public func refresh(_ token: Token, completion: @escaping (Result) -> Void) { + tokenProvider.refreshToken(token: token, completion: completion) + } + + public func refresh(_ token: Token) -> Single { + tokenProvider.refresh(token: token) + } + + public func certificate(phoneNumber: String) -> Single { + Single.just(PhoneValidationResponse(phoneNumber: "01012345678", authNumber: 123456)) + .map { $0.authNumber } + // request(type: PhoneValidationResponse.self, target: .certificate(phoneNumber: phoneNumber)) + } + + public func login(phoneNumber: String, deviceKey: String) -> Single { + tokenProvider.login(phoneNumber: phoneNumber, deviceKey: deviceKey) + } + + public func loginSNS(_ userSNSLoginRequest: AuthInterface.UserSNSLoginRequest) -> Single { + tokenProvider.loginSNS(userSNSLoginRequest) + } +} + diff --git a/Projects/Data/Src/Repository/Auth/AuthTarget.swift b/Projects/Data/Src/Repository/Auth/AuthTarget.swift new file mode 100644 index 00000000..c563fe83 --- /dev/null +++ b/Projects/Data/Src/Repository/Auth/AuthTarget.swift @@ -0,0 +1,44 @@ +// +// AuthTarget.swift +// Data +// +// Created by Kanghos on 6/3/24. +// + +import Foundation + +import Networks + +import Moya +import AuthInterface + +public enum AuthTarget { + case certificate(phoneNumber: String) + case checkExistence(phoneNumber: String) +} + +extension AuthTarget: BaseTargetType { + + public var path: String { + switch self { + case .certificate(let phoneNumber): + return "users/join/certification/phone-number/\(phoneNumber)" + case .checkExistence(let phoneNumber): + return "users/join/exist/user-info/\(phoneNumber)" + } + } + + public var method: Moya.Method { + switch self { + case .certificate, .checkExistence: return .get + } + } + + // Request의 파라미터를 결정한다. + public var task: Task { + switch self { + default: + return .requestPlain + } + } +} diff --git a/Projects/Data/Src/Repository/Auth/TokenProvider.swift b/Projects/Data/Src/Repository/Auth/TokenProvider.swift new file mode 100644 index 00000000..b36f65ef --- /dev/null +++ b/Projects/Data/Src/Repository/Auth/TokenProvider.swift @@ -0,0 +1,44 @@ +// +// TokenProvider.swift +// Data +// +// Created by Kanghos on 6/5/24. +// + +import Foundation + +import RxMoya +import Moya +import RxSwift + +import Networks + +import AuthInterface + +public final class DefaultTokenProvider: ProviderProtocol { + public typealias Target = TokenProviderTarget + + public var provider: MoyaProvider + + public init() { + provider = MoyaProvider() + } +} + +extension DefaultTokenProvider: TokenProvider { + public func refresh(token: AuthInterface.Token) -> RxSwift.Single { + request(type: Token.self, target: .refresh(token)) + } + + public func refreshToken(token: Token, completion: @escaping (Result) -> Void) { + request(target: .refresh(token), completion: completion) + } + + public func login(phoneNumber: String, deviceKey: String) -> Single { + request(type: Token.self, target: .login(phoneNumber: phoneNumber, deviceKey: deviceKey)) + } + + public func loginSNS(_ userSNSLoginRequest: UserSNSLoginRequest) -> Single { + request(type: Token.self, target: .loginSNS(request: userSNSLoginRequest)) + } +} diff --git a/Projects/Data/Src/Repository/Auth/TokenProviderTarget.swift b/Projects/Data/Src/Repository/Auth/TokenProviderTarget.swift new file mode 100644 index 00000000..60f88286 --- /dev/null +++ b/Projects/Data/Src/Repository/Auth/TokenProviderTarget.swift @@ -0,0 +1,52 @@ +// +// TokenProviderTarget.swift +// Data +// +// Created by Kanghos on 6/5/24. +// + +import Foundation + +import Networks + +import Moya +import AuthInterface + +public enum TokenProviderTarget { + case login(phoneNumber: String, deviceKey: String) + case loginSNS(request: UserSNSLoginRequest) + case refresh(Token) +} + +extension TokenProviderTarget: BaseTargetType { + + public var path: String { + switch self { + case .login: + return "users/login/normal" + case .loginSNS: + return "users/login/sns" + case .refresh: + return "users/login/refresh" + } + } + + public var method: Moya.Method { + switch self { + default: return .post + } + } + + // Request의 파라미터를 결정한다. + public var task: Task { + switch self { + case let .login(phoneNumber, deviceKey): + let request = LoginReq(phoneNumber: phoneNumber, deviceKey: deviceKey) + return .requestParameters(parameters: request.toDictionary(), encoding: JSONEncoding.default) + case let .loginSNS(snsDTO): + return .requestParameters(parameters: snsDTO.toDictionary(), encoding: JSONEncoding.default) + case let .refresh(token): + return .requestParameters(parameters: token.toDictionary(), encoding: JSONEncoding.default) + } + } +} diff --git a/Projects/Data/Src/Repository/Like/LikeTarget+SampleData.swift b/Projects/Data/Src/Repository/Like/LikeTarget+SampleData.swift index fcf9900f..f4282ac3 100644 --- a/Projects/Data/Src/Repository/Like/LikeTarget+SampleData.swift +++ b/Projects/Data/Src/Repository/Like/LikeTarget+SampleData.swift @@ -109,7 +109,7 @@ extension LikeTarget { "likeIdx": 1, "topic": "행복", "issue": "무엇을 할때 행복한가요?", - "userUuid": "user-uuid-1", + "userUuid": "user-uuid-5", "username": "유저1", "profileUrl": "profile-url", "age": 24, @@ -121,7 +121,7 @@ extension LikeTarget { "likeIdx": 3, "topic": "취미", "issue": "침대에 누워있고 싶지 않으세요?", - "userUuid": "user-uuid-2", + "userUuid": "user-uuid-6", "username": "유저2", "profileUrl": "profile-url", "age": 32, @@ -133,7 +133,7 @@ extension LikeTarget { "likeIdx": 154, "topic": "평화", "issue": "돈많은 백수가 되고싶네요", - "userUuid": "user-uuid-3", + "userUuid": "user-uuid-7", "username": "유저3", "profileUrl": "profile-url", "age": 64, @@ -145,7 +145,7 @@ extension LikeTarget { "likeIdx": 893, "topic": "취미", "issue": "침대에 누워있고 싶지 않으세요?", - "userUuid": "user-uuid-4", + "userUuid": "user-uuid-8", "username": "유저4", "profileUrl": "profile-url", "age": 27, @@ -157,7 +157,7 @@ extension LikeTarget { "likeIdx": 891, "topic": "취미", "issue": "침대에 누워있고 싶지 않으세요?", - "userUuid": "user-uuid-4", + "userUuid": "user-uuid-9", "username": "유저5", "profileUrl": "profile-url", "age": 27, @@ -169,7 +169,7 @@ extension LikeTarget { "likeIdx": 88, "topic": "행복", "issue": "침대에 누워있고 싶지 않으세요?", - "userUuid": "user-uuid-4", + "userUuid": "user-uuid-10", "username": "유저6", "profileUrl": "profile-url", "age": 27, diff --git a/Projects/Data/Src/Repository/Local/UserDefaultTokenStore.swift b/Projects/Data/Src/Repository/Local/UserDefaultTokenStore.swift new file mode 100644 index 00000000..75862176 --- /dev/null +++ b/Projects/Data/Src/Repository/Local/UserDefaultTokenStore.swift @@ -0,0 +1,34 @@ +// +// UserDefaultTokenStore.swift +// Data +// +// Created by Kanghos on 6/3/24. +// + +import Foundation + +import AuthInterface +import RxSwift + +public final class UserDefaultTokenStore: TokenStore { + enum Key { + static let token = "Token" + } + public var cachedToken: Token? + + public init () {} + + public func saveToken(token: AuthInterface.Token) { + cachedToken = token + try? UserDefaults.standard.setCodableObject(token, forKey: Key.token) + } + + public func getToken() throws -> Token { + try UserDefaults.standard.getCodableObject(forKey: Key.token, as: AuthInterface.Token.self) + } + + public func clearToken() { + cachedToken = nil + UserDefaults.standard.removeObject(forKey: Key.token) + } +} diff --git a/Projects/Data/Src/Repository/Local/UserInfoRepositoory.swift b/Projects/Data/Src/Repository/Local/UserInfoRepositoory.swift new file mode 100644 index 00000000..98fd75e6 --- /dev/null +++ b/Projects/Data/Src/Repository/Local/UserInfoRepositoory.swift @@ -0,0 +1,101 @@ +// +// UserInfoRepositoory.swift +// Data +// +// Created by Kanghos on 5/29/24. +// + +import Foundation +import RxSwift +import SignUpInterface +import Core + +public class UserDefaultUserInfoRepository: UserInfoRepositoryInterface { + enum Key { + static let userInfo = "userInfo" + static let phoneNumber = "phoneNumber" + } + + public init () {} + + public func savePhoneNumber(_ phoneNumber: String) { + UserDefaults.standard.setValue(phoneNumber, forKey: Key.phoneNumber) + } + + public func fetchPhoneNumber() -> Single { + .create { observer in + guard let phoneNumber = UserDefaults.standard.string(forKey: Key.phoneNumber) else { + observer(.failure(NSError(domain: "UserDefaultRepository", code: 0))) + return Disposables.create() + } + observer(.success(phoneNumber)) + + return Disposables.create { } + } + } + + public func fetchUserInfo() -> Single { + do { + let userinfo = try UserDefaults.standard.getCodableObject(forKey: Key.userInfo, as: UserInfo.self) + return .just(userinfo) + } catch { + return .error(error) + } + } + + public func updateUserInfo(userInfo: UserInfo) { + try? UserDefaults.standard.setCodableObject(userInfo, forKey: Key.userInfo) + } + + public func deleteUserInfo() { + UserDefaults.standard.removeObject(forKey: Key.userInfo) + } + + public func fetchUserPhotos(key: String, fileNames: [String]) -> Single<[Data]> { + Single.create { observer in + var datas: [Data] = [] + let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appending(path: key, directoryHint: .isDirectory) + + for fileName in fileNames { + do { + let fileURL = path.appending(component: fileName) + let data = try Data(contentsOf: fileURL) + datas.append(data) + } catch { + print("Failed: Filed Fetch User Photos") + observer(.failure(error)) + return Disposables.create { } + } + } + observer(.success(datas)) + return Disposables.create { } + } + } + + public func saveUserPhotos(key: String, datas: [Data]) -> Single<[String]> { + return Single.create { observer in + var urls: [String] = [] + + let userDomain = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let directory = userDomain.appendingPathComponent(key, isDirectory: true) + + do { + if !FileManager.default.fileExists(atPath: directory.path()) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + } + for (index, data) in datas.enumerated() { + let fileName = "\(index).jpeg" + let url = directory.appendingPathComponent(fileName) + try data.write(to: url) + urls.append(fileName) + } + } catch { + print(error.localizedDescription) + observer(.failure(error)) + return Disposables.create { } + } + observer(.success(urls)) + return Disposables.create { } + } + } +} diff --git a/Projects/Data/Src/Repository/MyPage/MyPageRepository.swift b/Projects/Data/Src/Repository/MyPage/MyPageRepository.swift index c4356d58..50725ca5 100644 --- a/Projects/Data/Src/Repository/MyPage/MyPageRepository.swift +++ b/Projects/Data/Src/Repository/MyPage/MyPageRepository.swift @@ -8,9 +8,12 @@ import Foundation import MyPageInterface +import SignUpInterface + import Networks import RxSwift +import RxMoya import Moya public final class MyPageRepository: ProviderProtocol { @@ -23,7 +26,5 @@ public final class MyPageRepository: ProviderProtocol { extension MyPageRepository: MyPageRepositoryInterface { - public func test() { - - } + } diff --git a/Projects/Data/Src/Repository/MyPage/MyPageTarget.swift b/Projects/Data/Src/Repository/MyPage/MyPageTarget.swift index 474e0ea5..f69a5b1a 100644 --- a/Projects/Data/Src/Repository/MyPage/MyPageTarget.swift +++ b/Projects/Data/Src/Repository/MyPage/MyPageTarget.swift @@ -8,29 +8,32 @@ import Networks import Moya +import SignUpInterface public enum MyPageTarget { - case test + case blockUserFriendContact(request: UserFriendContactReq) } extension MyPageTarget: BaseTargetType { public var path: String { switch self { - case .test: - return "" + case .blockUserFriendContact: + return "user/friend-contact-list" } } public var method: Moya.Method { switch self { - case .test: - return .get + case .blockUserFriendContact: + return .post } } public var task: Moya.Task { switch self { - case .test: + case let .blockUserFriendContact(request): + return .requestParameters(parameters: request.toDictionary(), encoding: JSONEncoding.default) + default: return .requestPlain } } diff --git a/Projects/Data/Src/Repository/SignUp/SignUpRepository.swift b/Projects/Data/Src/Repository/SignUp/SignUpRepository.swift new file mode 100644 index 00000000..59abc0af --- /dev/null +++ b/Projects/Data/Src/Repository/SignUp/SignUpRepository.swift @@ -0,0 +1,64 @@ +// +// SignUpRepository.swift +// Data +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +import SignUpInterface +import AuthInterface +import Networks + +import RxSwift +import RxMoya +import Moya + +public final class SignUpRepository: ProviderProtocol { + + public typealias Target = SignUpTarget + public var provider: MoyaProvider + + public init(isStub: Bool, sampleStatusCode: Int, customEndpointClosure: ((SignUpTarget) -> Moya.Endpoint)?) { + self.provider = Self.consProvider(isStub, sampleStatusCode, customEndpointClosure) + } + + public convenience init() { + self.init(isStub: false, sampleStatusCode: 200, customEndpointClosure: nil) + } +} + +extension SignUpRepository: SignUpRepositoryInterface { + public func uploadImage(data: [Data]) -> RxSwift.Single<[String]> { + .just(["test.jpg", "test2.jpg"]) + } + + public func signUp(_ signUpRequest: SignUpInterface.SignUpReq) -> RxSwift.Single { + request(type: Token.self, target: .signUp(signUpReq: signUpRequest)) + } + + public func certificate(phoneNumber: String) -> RxSwift.Single { + Single.just(PhoneValidationResponse(phoneNumber: "01012345678", authNumber: 123456)) + .map { $0.authNumber } + + // request(type: PhoneValidationResponse.self, target: .certificate(phoneNumber: phoneNumber)) + } + + public func checkNickname(nickname: String) -> RxSwift.Single { + request(type: UserNicknameValidRes.self, target: .checkNickname(nickname: nickname)) + .map { $0.isDuplicate } + } + + public func idealTypes() -> RxSwift.Single<[EmojiType]> { + request(type: [EmojiType].self, target: .idealTypes) + } + + public func interests() -> RxSwift.Single<[EmojiType]> { + request(type: [EmojiType].self, target: .interests) + } + + public func fetchAgreements() -> Single { + request(type: Agreement.self, target: .agreement) + } +} diff --git a/Projects/Data/Src/Repository/SignUp/SignUpTarget+SampleData.swift b/Projects/Data/Src/Repository/SignUp/SignUpTarget+SampleData.swift new file mode 100644 index 00000000..1e78b32d --- /dev/null +++ b/Projects/Data/Src/Repository/SignUp/SignUpTarget+SampleData.swift @@ -0,0 +1,78 @@ +// +// SignUpTarget+SampleData.swift +// Data +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +import Networks + +import Moya + +extension SignUpTarget { + public var sampleData: Data { + switch self { + case .certificate: + return Data( + """ + { + "phoneNumber": "01012345678", + "authNumber": 123456 + } + """.utf8) + case .checkNickname: + return Data( + """ + { + "isDuplicate": true + } + """.utf8) + case .checkExistence: + return Data( + """ + { + "isSignUp": false, + "typeList": [ + "NORMAL", + "KAKAO", + "NAVER", + "GOOGLE" + ] + } + """.utf8) + + case .agreement: + return Data( + """ + [ + { + "name":"serviceUseAgree","subject":"이용약관을 읽고, 이해했으며, 동의합니다.", + "isRequired":true, + "description":"", + "detailLink":"https://www.notion.so/janechoi/526c51e9cb584f29a7c16251914bb3cb?pvs=4" + }, + { + "name":"personalPrivacyInfoAgree", + "subject":"개인 정보 처리 방침을 읽고, 이해했으며, 동의합니다.", + "isRequired":true, + "description":"", + "detailLink":"https://www.notion.so/janechoi/5923a3c20259459bbacaff41290fc615?pvs=4" + }, + { + "name":"marketingAgree","subject":"마케팅 정보 수신 동의", + "isRequired":false, + "description":"폴링에서 제공하는 이벤트/혜택 등 다양한 정보를 Push 알림으로 받아보실 수 있습니다.", + "detailLink":"" + } + ] + """.utf8) + + default: + return Data( + """ + """.utf8) + } + } +} diff --git a/Projects/Data/Src/Repository/SignUp/SignUpTarget.swift b/Projects/Data/Src/Repository/SignUp/SignUpTarget.swift new file mode 100644 index 00000000..cf50651d --- /dev/null +++ b/Projects/Data/Src/Repository/SignUp/SignUpTarget.swift @@ -0,0 +1,70 @@ +// +// SignUpTarget.swift +// Data +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +import Networks + +import Moya +import SignUpInterface + +public enum SignUpTarget { + case certificate(phoneNumber: String) + case checkExistence(phoneNumber: String) + case checkNickname(nickname: String) + case idealTypes + case interests + case block(contacts: UserFriendContactReq) + case signUp(signUpReq: SignUpReq) + case agreement +} + +extension SignUpTarget: BaseTargetType { + + public var path: String { + switch self { + case .certificate(let phoneNumber): + return "users/join/certification/phone-number/\(phoneNumber)" + case .checkExistence(let phoneNumber): + return "users/join/exist/user-info/\(phoneNumber)" + case .checkNickname(let nickname): + return "users/join/nick-name/duplicate-check/\(nickname)" + case .idealTypes: + return "ideal-types" + case .interests: + return "interests" + case .block: + return "user/friend-contact-list" + case .signUp: + return "users/join/signup" + case .agreement: + return "users/join/agreements/main-category" + } + } + + public var method: Moya.Method { + switch self { + case .block: + return .post + case .signUp: + return .post + default: return .get + } + } + + // Request의 파라미터를 결정한다. + public var task: Task { + switch self { + case let .block(contacts): + return .requestParameters(parameters: contacts.toDictionary(), encoding: JSONEncoding.default) + case let .signUp(dto): + return .requestParameters(parameters: dto.toDictionary(), encoding: JSONEncoding.default) + default: + return .requestPlain + } + } +} diff --git a/Projects/Data/Src/Service/ContactService.swift b/Projects/Data/Src/Service/ContactService.swift new file mode 100644 index 00000000..a2463b78 --- /dev/null +++ b/Projects/Data/Src/Service/ContactService.swift @@ -0,0 +1,58 @@ +// +// ContactService.swift +// Data +// +// Created by Kanghos on 5/12/24. +// + +import Foundation +import RxSwift +import SignUpInterface +import Contacts + +enum ContactError: Error { + case fetchError(message: String) +} + +public final class ContactService: ContactServiceType { + + public init() { } + public func fetchContact() -> Single<[ContactType]> { + return Single.create { [unowned self] single in + self.fetchContacts { result in + switch result { + case .success(let contacts): + single(.success(contacts)) + case .failure(let error): + single(.failure(error)) + } + } + return Disposables.create() + } + } + + private func fetchContacts(completion: @escaping (Result<[ContactType], ContactError>) -> Void) { + let store = CNContactStore() + store.requestAccess(for: .contacts) { granted, error in + guard granted == true, error == nil else { + return + } + + let keys = [CNContactGivenNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor] + let request = CNContactFetchRequest(keysToFetch: keys) + var contacts: [ContactType] = [] + + do { + try store.enumerateContacts(with: request) { contact, _ in + let name = contact.givenName + let phoneNumber = contact.phoneNumbers.first?.value.stringValue ?? "" + let contact = ContactType(name: name, phoneNumber: phoneNumber) + contacts.append(contact) + } + completion(.success(contacts)) + } catch { + completion(.failure(.fetchError(message: error.localizedDescription))) + } + } + } +} diff --git a/Projects/Data/Src/Service/KakaoAPIService.swift b/Projects/Data/Src/Service/KakaoAPIService.swift new file mode 100644 index 00000000..15f5ad2d --- /dev/null +++ b/Projects/Data/Src/Service/KakaoAPIService.swift @@ -0,0 +1,40 @@ +// +// KakaoAPIService.swift +// Data +// +// Created by Kanghos on 5/12/24. +// + +import Foundation + +import SignUpInterface +import Networks + +import Moya +import RxSwift + +public final class KakaoAPIService: ProviderProtocol { + public typealias Target = KakaoAPITarget + public var provider: MoyaProvider + + public init(isStub: Bool, sampleStatusCode: Int, customEndpointClosure: ((Target) -> Endpoint)?) { + self.provider = Self.consProvider(isStub, sampleStatusCode, customEndpointClosure) + } + + public convenience init() { + self.init(isStub: false, sampleStatusCode: 200, customEndpointClosure: nil) + } +} + + +extension KakaoAPIService: KakaoAPIServiceType { + public func fetchLocationByAddress(address: String) -> Single { + request(type: KakaoSearchRes.self, target: .searchAddress(query: address)) + .map { $0.toDomain() } + } + + public func fetchLocationByCoordinate2d(longitude: Double, latitude: Double) -> Single { + request(type: KakaoCoordinateRes.self, target: .searchCoordinate(longitude: longitude, latitude: latitude)) + .map { $0.toDomain() } + } +} diff --git a/Projects/Data/Src/Service/KakaoAPITarget.swift b/Projects/Data/Src/Service/KakaoAPITarget.swift new file mode 100644 index 00000000..6f30ea9a --- /dev/null +++ b/Projects/Data/Src/Service/KakaoAPITarget.swift @@ -0,0 +1,62 @@ +// +// KakaoAPITarget.swift +// Data +// +// Created by Kanghos on 5/12/24. +// + +import Foundation + +import Networks + +import Moya + +public enum KakaoAPITarget { + case searchAddress(query: String) + case searchCoordinate(longitude: Double, latitude: Double) +} + +extension KakaoAPITarget: BaseTargetType { + + public var baseURL: URL { + URL(string: "https://dapi.kakao.com")! + } + + public var path: String { + switch self { + case .searchAddress: + return "/v2/local/search/address.json" + case .searchCoordinate: + return "/v2/local/geo/coord2regioncode.json" + } + } + + public var headers: [String : String]? { + ["Authorization": "KakaoAK fd26fcdea335b93122163284d3fd4047"] + } + + public var method: Moya.Method { + switch self { + default: return .get + } + } + + var parameters: [String: Any] { + switch self { + case .searchAddress(let query): + return ["query": query] + case .searchCoordinate(let longitude, let latitude): + return ["x": longitude, "y": latitude] + } + } + + // Request의 파라미터를 결정한다. + public var task: Task { + switch self { + case.searchAddress: + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + case .searchCoordinate: + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + } + } +} diff --git a/Projects/Data/Src/Service/LocationService.swift b/Projects/Data/Src/Service/LocationService.swift new file mode 100644 index 00000000..16ef8ae4 --- /dev/null +++ b/Projects/Data/Src/Service/LocationService.swift @@ -0,0 +1,76 @@ +// +// LocationService.swift +// Data +// +// Created by Kanghos on 5/12/24. +// + +import Foundation +import CoreLocation +import SignUpInterface + +import RxSwift + +public final class LocationService: NSObject, LocationServiceType { + private let manager = CLLocationManager() + private let geoCoder = CLGeocoder() + public let publisher = PublishSubject() + private let location = PublishSubject() + + private var disposeBag = DisposeBag() + + public override init() { + super.init() + + bind() + } + + private func bind() { + manager.delegate = self + + location + .map { location in + LocationReq(address: "", regionCode: 0, lat: location.coordinate.latitude, lon: location.coordinate.longitude) + }.bind(to: publisher) + .disposed(by: disposeBag) + } + + public func requestAuthorization() { + manager.requestWhenInUseAuthorization() + } + + public func handleAuthorization(granted: @escaping (Bool) -> Void) { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + granted(false) + case .restricted, .denied: + granted(false) + location.onError(LocationError.denied) + case .authorizedAlways, .authorizedWhenInUse: + granted(true) + @unknown default: + granted(false) + } + } + + public func requestLocation() { + manager.requestLocation() + } +} + +extension LocationService: CLLocationManagerDelegate { + public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + + } + + public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let location = locations.last { + self.location.onNext(location) + } + } + + public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print(error.localizedDescription) + } +} diff --git a/Projects/Domain/Src/Model/User/UserProfilePhoto.swift b/Projects/Domain/Src/Model/User/UserProfilePhoto.swift index 1d7c79a3..0b57354b 100644 --- a/Projects/Domain/Src/Model/User/UserProfilePhoto.swift +++ b/Projects/Domain/Src/Model/User/UserProfilePhoto.swift @@ -7,7 +7,7 @@ import Foundation -public struct UserProfilePhoto { +public struct UserProfilePhoto: Codable { public let identifier = UUID() public let url: String public let priority: Int diff --git a/Projects/Features/Auth/Demo/Src/AppDelegate+Register.swift b/Projects/Features/Auth/Demo/Src/AppDelegate+Register.swift new file mode 100644 index 00000000..f1679384 --- /dev/null +++ b/Projects/Features/Auth/Demo/Src/AppDelegate+Register.swift @@ -0,0 +1,57 @@ +// +// AppDelegate+Register.swift +// AuthDemo +// +// Created by Kanghos on 6/3/24. +// + +import Foundation + +import Core + +import SignUp +import SignUpInterface +import AuthInterface +import Auth +import Data + +extension AppDelegate { + var container: DIContainer { + DIContainer.shared + } + + func registerDependencies() { + let tokenStore = UserDefaultTokenStore() + let tokenProvider = DefaultTokenProvider() + + container.register( + interface: SignUpUseCaseInterface.self, + implement: { + SignUpUseCase( + repository: SignUpRepository(), + locationService: LocationService(), + kakaoAPIService: KakaoAPIService(), + contactService: ContactService(), + tokenStore: tokenStore + ) + } + ) + + container.register( + interface: AuthUseCaseInterface.self, + implement: { + AuthUseCase( + authRepository: AuthRepository(tokenStore: tokenStore, tokenProvider: tokenProvider), + tokenStore: tokenStore + ) + }) + + container.register( + interface: UserInfoUseCaseInterface.self, + implement: { + UserInfoUseCase(repository: UserDefaultUserInfoRepository()) + }) + } +} + + diff --git a/Projects/Features/Auth/Demo/Src/AppDelegate.swift b/Projects/Features/Auth/Demo/Src/AppDelegate.swift new file mode 100644 index 00000000..fe9001cf --- /dev/null +++ b/Projects/Features/Auth/Demo/Src/AppDelegate.swift @@ -0,0 +1,27 @@ +// +// AppDelegate.swift +// ProjectDescriptionHelpers +// +// Created by Kanghos on 6/3/24. +// + +import UIKit +//import FirebaseCore + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + registerDependencies() + + return true + } + + // MARK: UISceneSession Lifecycle + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} diff --git a/Projects/Features/Auth/Demo/Src/Coordinator/AppBuilder.swift b/Projects/Features/Auth/Demo/Src/Coordinator/AppBuilder.swift new file mode 100644 index 00000000..9e5d15bd --- /dev/null +++ b/Projects/Features/Auth/Demo/Src/Coordinator/AppBuilder.swift @@ -0,0 +1,51 @@ +// +// AppBuilder.swift +// AuthDemo +// +// Created by Kanghos on 6/3/24. +// + +import UIKit + +import DSKit + +import Auth +import AuthInterface +import SignUp +import SignUpInterface + +public protocol AppRootBuildable { + func build() -> LaunchCoordinating +} + +public final class AppRootBuilder: AppRootBuildable { + public init() { } + + private lazy var signUpBuildable: SignUpBuildable = { + SignUpBuilder() + }() + + private lazy var authBuildable: AuthBuildable = { + AuthBuilder(signUpBuilable: signUpBuildable) + }() + + private lazy var launchBuildable: LaunchBuildable = { + LaunchBuilder() + }() + + public func build() -> LaunchCoordinating { + + // MARK: Launcher + + let viewController = NavigationViewControllable() + + let coordinator = AppCoordinator( + viewControllable: viewController, + authBuildable: self.authBuildable, + launchBuildable: launchBuildable + ) + + return coordinator + } +} + diff --git a/Projects/Features/Auth/Demo/Src/Coordinator/AppCoordinator.swift b/Projects/Features/Auth/Demo/Src/Coordinator/AppCoordinator.swift new file mode 100644 index 00000000..7105a3f0 --- /dev/null +++ b/Projects/Features/Auth/Demo/Src/Coordinator/AppCoordinator.swift @@ -0,0 +1,98 @@ +// +// AppCoordinator.swift +// AuthDemo +// +// Created by Kanghos on 6/3/24. +// + +import UIKit +import SignUpInterface +import AuthInterface +import Core +import DSKit + +protocol AppCoordinating { + func authFlow() +} + +final class AppCoordinator: LaunchCoordinator, AppCoordinating { + + private let authBuildable: AuthBuildable + private let launchBuildable: LaunchBuildable + + init( + viewControllable: ViewControllable, + authBuildable: AuthBuildable, + launchBuildable: LaunchBuildable + ) { + self.authBuildable = authBuildable + self.launchBuildable = launchBuildable + super.init(viewControllable: viewControllable) + } + + public override func start() { + launchFlow() + } + + func launchFlow() { + let coordinator = self.launchBuildable.build(rootViewControllable: self.viewControllable) + attachChild(coordinator) + coordinator.delegate = self + coordinator.start() + } + + // MARK: - public + func authFlow() { + let authCoordinator = self.authBuildable.build() + + attachChild(authCoordinator) + authCoordinator.delegate = self + authCoordinator.start() + } + + class MainViewController: TFBaseViewController { + override func makeUI() { + let button = UIButton() + button.setTitle("가입내역 지우기", for: .normal) + button.backgroundColor = DSKitAsset.Color.primary500.color + self.view.addSubview(button) + button.addAction(UIAction {_ in + UserDefaults.standard.removeObject(forKey: "phoneNumber") + }, for: .touchUpInside) + button.layer.cornerRadius = 16 + button.clipsToBounds = true + + button.snp.makeConstraints { + $0.center.equalToSuperview() + $0.height.equalTo(60) + $0.width.equalToSuperview().inset(80) + } + } + } + + func mainFlow() { + replaceWindowRootViewController(rootViewController: viewControllable) + let vc = MainViewController() + + self.viewControllable.setViewControllers([vc]) + } +} + +extension AppCoordinator: AuthCoordinatingDelegate { + func detachAuth(_ coordinator: Core.Coordinator) { + detachChild(coordinator) + mainFlow() + } +} + +extension AppCoordinator: LaunchCoordinatingDelegate { + func finishFlow(_ coordinator: Coordinator, _ action: LaunchAction) { + detachChild(coordinator) + switch action { + case .needAuth: + authFlow() + case .toMain: + mainFlow() + } + } +} diff --git a/Projects/Features/Auth/Demo/Src/SceneDelegate.swift b/Projects/Features/Auth/Demo/Src/SceneDelegate.swift new file mode 100644 index 00000000..4af0672e --- /dev/null +++ b/Projects/Features/Auth/Demo/Src/SceneDelegate.swift @@ -0,0 +1,40 @@ +// +// SceneDelegate.swift +// AuthDemo +// +// Created by Kanghos on 6/3/24. +// + +import UIKit + +import Core +import DSKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + var launcher: LaunchCoordinating? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + + let appCoordinator = AppRootBuilder().build() + + self.launcher = appCoordinator + self.launcher?.launch(window: window) + + self.window = window + } + + func sceneDidDisconnect(_ scene: UIScene) { } + + func sceneDidBecomeActive(_ scene: UIScene) { } + + func sceneWillResignActive(_ scene: UIScene) { } + + func sceneWillEnterForeground(_ scene: UIScene) { } + + func sceneDidEnterBackground(_ scene: UIScene) { } +} diff --git a/Projects/Features/Auth/Interface/Src/AuthUseCaseInterface.swift b/Projects/Features/Auth/Interface/Src/AuthUseCaseInterface.swift new file mode 100644 index 00000000..53d33269 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/AuthUseCaseInterface.swift @@ -0,0 +1,18 @@ +// +// AuthUseCaseInterface.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +import RxSwift + +public protocol AuthUseCaseInterface { + func certificate(phoneNumber: String) -> Single + func checkUserExists(phoneNumber: String) -> Single + func login(phoneNumber: String, deviceKey: String) -> Single + func loginSNS(_ request: UserSNSLoginRequest) -> Single + func refresh() -> Single +} diff --git a/Projects/Features/Auth/Interface/Src/Coordinator/AuthBuildable.swift b/Projects/Features/Auth/Interface/Src/Coordinator/AuthBuildable.swift new file mode 100644 index 00000000..0359b750 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Coordinator/AuthBuildable.swift @@ -0,0 +1,14 @@ +// +// AuthBuildable.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +import Core + +public protocol AuthBuildable { + func build() -> AuthCoordinating +} diff --git a/Projects/Features/Auth/Interface/Src/Coordinator/AuthCoordinating+Action.swift b/Projects/Features/Auth/Interface/Src/Coordinator/AuthCoordinating+Action.swift new file mode 100644 index 00000000..771b04d0 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Coordinator/AuthCoordinating+Action.swift @@ -0,0 +1,14 @@ +// +// AuthCoordinating+Action.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +public enum AuthCoordinatingAction { + case tologinType(_ type: SNSType) + case toSignUp(phoneNumber: String) + case toMain +} diff --git a/Projects/Features/Auth/Interface/Src/Coordinator/AuthCoordinating.swift b/Projects/Features/Auth/Interface/Src/Coordinator/AuthCoordinating.swift new file mode 100644 index 00000000..aa2b2b37 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Coordinator/AuthCoordinating.swift @@ -0,0 +1,26 @@ +// +// AuthCoordinating.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +import Core + +public protocol AuthCoordinatingDelegate: AnyObject { + func detachAuth(_ coordinator: Coordinator) +} + +public protocol AuthCoordinating: Coordinator { + var delegate: AuthCoordinatingDelegate? { get set } + + func launchFlow() + + func rootFlow() + + func phoneNumberFlow() + + func snsFlow(type: SNSType) +} diff --git a/Projects/Features/Auth/Interface/Src/Coordinator/AuthLaunchCoordinating.swift b/Projects/Features/Auth/Interface/Src/Coordinator/AuthLaunchCoordinating.swift new file mode 100644 index 00000000..dafb5f08 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Coordinator/AuthLaunchCoordinating.swift @@ -0,0 +1,24 @@ +// +// AuthLaunchCoordinating.swift +// AuthInterface +// +// Created by Kanghos on 6/4/24. +// + +import Foundation +import Core + +public protocol LaunchCoordinatingDelegate: AnyObject { + func finishFlow(_ coordinator: Coordinator, _ action: LaunchAction) +} + +public enum LaunchAction { + case needAuth + case toMain +} + +public protocol AuthLaunchCoordinating: Coordinator { + var delegate: LaunchCoordinatingDelegate? { get set } + + func launchFlow() +} diff --git a/Projects/Features/Auth/Interface/Src/Coordinator/LaunchBuildable.swift b/Projects/Features/Auth/Interface/Src/Coordinator/LaunchBuildable.swift new file mode 100644 index 00000000..88ba7493 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Coordinator/LaunchBuildable.swift @@ -0,0 +1,14 @@ +// +// LaunchBuildable.swift +// Auth +// +// Created by Kanghos on 6/4/24. +// + +import Foundation + +import Core + +public protocol LaunchBuildable { + func build(rootViewControllable: ViewControllable) -> AuthLaunchCoordinating +} diff --git a/Projects/Features/Auth/Interface/Src/DTO/Request/UserSNSLoginRequest.swift b/Projects/Features/Auth/Interface/Src/DTO/Request/UserSNSLoginRequest.swift new file mode 100644 index 00000000..7b6d14b2 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/DTO/Request/UserSNSLoginRequest.swift @@ -0,0 +1,22 @@ +// +// UserSNSLoginRequest.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +public struct UserSNSLoginRequest: Codable { + public let email: String + public let snsType: SNSType + public let snsUniqueId: String + public let deviceKey: String + + public init(email: String, snsType: SNSType, snsUniqueId: String, deviceKey: String) { + self.email = email + self.snsType = snsType + self.snsUniqueId = snsUniqueId + self.deviceKey = deviceKey + } +} diff --git a/Projects/Features/Auth/Interface/Src/DTO/Response/UserSignUpInfoRes.swift b/Projects/Features/Auth/Interface/Src/DTO/Response/UserSignUpInfoRes.swift new file mode 100644 index 00000000..684583cf --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/DTO/Response/UserSignUpInfoRes.swift @@ -0,0 +1,13 @@ +// +// UserSignUpInfoRes.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +public struct UserSignUpInfoRes: Codable { + public let isSignUp: Bool + public let typeList: [SNSType] +} diff --git a/Projects/Features/Auth/Interface/Src/File.swift b/Projects/Features/Auth/Interface/Src/File.swift deleted file mode 100644 index 436c7e7d..00000000 --- a/Projects/Features/Auth/Interface/Src/File.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// AuthInterface -// -// Created by Hoo's MacBookPro on 12/3/23. -// - -import Foundation diff --git a/Projects/Features/Auth/Interface/Src/Model/AuthError.swift b/Projects/Features/Auth/Interface/Src/Model/AuthError.swift new file mode 100644 index 00000000..7f9b9d86 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Model/AuthError.swift @@ -0,0 +1,12 @@ +// +// AuthError.swift +// AuthInterface +// +// Created by Kanghos on 6/6/24. +// + +import Foundation + +public enum AuthError: Error { + case invalidToken +} diff --git a/Projects/Features/Auth/Interface/Src/Model/SNSType.swift b/Projects/Features/Auth/Interface/Src/Model/SNSType.swift new file mode 100644 index 00000000..47c25fd7 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Model/SNSType.swift @@ -0,0 +1,16 @@ +// +// SNSType.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +public enum SNSType: String, Codable { + case kakao = "KAKAO" + case naver = "NAVER" + case google = "GOOGLE" + case apple = "APPLE" + case normal = "NORMAL" +} diff --git a/Projects/Features/Auth/Interface/Src/Model/Token.swift b/Projects/Features/Auth/Interface/Src/Model/Token.swift new file mode 100644 index 00000000..c1b292d4 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/Model/Token.swift @@ -0,0 +1,18 @@ +// +// Token.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +public struct Token: Codable { + public let accessToken: String + public let accessTokenExpiresIn: Double + + public init(accessToken: String, accessTokenExpiresIn: Double) { + self.accessToken = accessToken + self.accessTokenExpiresIn = accessTokenExpiresIn + } +} diff --git a/Projects/Features/Auth/Interface/Src/RepositoryInterface/AuthRepositoryInterface.swift b/Projects/Features/Auth/Interface/Src/RepositoryInterface/AuthRepositoryInterface.swift new file mode 100644 index 00000000..fe4a0398 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/RepositoryInterface/AuthRepositoryInterface.swift @@ -0,0 +1,19 @@ +// +// AuthRepositoryInterface.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +import RxSwift + +public protocol AuthRepositoryInterface { + func certificate(phoneNumber: String) -> Single + func checkUserExist(phoneNumber: String) -> Single + func login(phoneNumber: String, deviceKey: String) -> Single + func loginSNS(_ userSNSLoginRequest: UserSNSLoginRequest) -> Single + func refresh(_ token: Token, completion: @escaping (Result) -> Void) + func refresh(_ token: Token) -> Single +} diff --git a/Projects/Features/Auth/Interface/Src/RepositoryInterface/TokenProvider.swift b/Projects/Features/Auth/Interface/Src/RepositoryInterface/TokenProvider.swift new file mode 100644 index 00000000..c8d5fda2 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/RepositoryInterface/TokenProvider.swift @@ -0,0 +1,16 @@ +// +// TokenProvider.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation +import RxSwift + +public protocol TokenProvider { + func refreshToken(token: Token, completion: @escaping (Result) -> Void) + func refresh(token: Token) -> Single + func login(phoneNumber: String, deviceKey: String) -> Single + func loginSNS(_ userSNSLoginRequest: UserSNSLoginRequest) -> Single +} diff --git a/Projects/Features/Auth/Interface/Src/RepositoryInterface/TokenStore.swift b/Projects/Features/Auth/Interface/Src/RepositoryInterface/TokenStore.swift new file mode 100644 index 00000000..34ad36a8 --- /dev/null +++ b/Projects/Features/Auth/Interface/Src/RepositoryInterface/TokenStore.swift @@ -0,0 +1,16 @@ +// +// TokenStore.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +import RxSwift +public protocol TokenStore { + var cachedToken: Token? { get set } + func saveToken(token: Token) + func getToken() throws -> Token + func clearToken() +} diff --git a/Projects/Features/Auth/Project.swift b/Projects/Features/Auth/Project.swift index 76eb61eb..2ea1e2f0 100644 --- a/Projects/Features/Auth/Project.swift +++ b/Projects/Features/Auth/Project.swift @@ -22,9 +22,18 @@ let project = Project( implementation: .Auth, dependencies: [ .feature(interface: .Auth), + .feature(interface: .SignUp), .dsKit ] - ) + ), + .feature( + demo: .Auth, + dependencies: [ + .feature(implementation: .SignUp), + .feature(implementation: .Auth), + .data + ] + ) ] ) diff --git a/Projects/Features/SignUp/Src/SignUpRoot/PhoneNumber/PhoneCertificationViewController.swift b/Projects/Features/Auth/Src/AuthRoot/Certificate/PhoneCertificationViewController.swift similarity index 95% rename from Projects/Features/SignUp/Src/SignUpRoot/PhoneNumber/PhoneCertificationViewController.swift rename to Projects/Features/Auth/Src/AuthRoot/Certificate/PhoneCertificationViewController.swift index 2d206d01..3930de4f 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/PhoneNumber/PhoneCertificationViewController.swift +++ b/Projects/Features/Auth/Src/AuthRoot/Certificate/PhoneCertificationViewController.swift @@ -226,11 +226,10 @@ final class PhoneCertificationViewController: TFBaseViewController { override func viewDidLoad() { super.viewDidLoad() keyBoardSetting() - setupAccessibilityIdentifier() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) phoneNumTextField.becomeFirstResponder() } @@ -280,10 +279,10 @@ final class PhoneCertificationViewController: TFBaseViewController { .disposed(by: disposeBag) output.error - .asSignal() - .emit { - print($0) - }.disposed(by: disposeBag) + .drive(with: self, onNext: { owner, error in + print(error.localizedDescription) + }) + .disposed(by: disposeBag) output.clearButtonTapped .drive(phoneNumTextField.rx.text) @@ -346,10 +345,6 @@ final class PhoneCertificationViewController: TFBaseViewController { .map { return $0.color } .drive(timerLabel.rx.textColor) .disposed(by: disposeBag) - - output.navigatorDisposble - .drive() - .disposed(by: disposeBag) } func keyBoardSetting() { @@ -378,11 +373,3 @@ final class PhoneCertificationViewController: TFBaseViewController { .disposed(by: disposeBag) } } - -extension PhoneCertificationViewController { - - private func setupAccessibilityIdentifier() { - phoneNumTextField.accessibilityIdentifier = AccessibilityIdentifier.phoneNumberTextField - verifyBtn.accessibilityIdentifier = AccessibilityIdentifier.verifyBtn - } -} diff --git a/Projects/Features/Auth/Src/AuthRoot/Certificate/PhoneCertificationViewModel.swift b/Projects/Features/Auth/Src/AuthRoot/Certificate/PhoneCertificationViewModel.swift new file mode 100644 index 00000000..7d3c051a --- /dev/null +++ b/Projects/Features/Auth/Src/AuthRoot/Certificate/PhoneCertificationViewModel.swift @@ -0,0 +1,224 @@ +// +// PhoneCertificationViewModel.swift +// DSKit +// +// Created by Hoo's MacBookPro on 2023/08/03. +// + +import Foundation + +import AuthInterface +import SignUpInterface +import DSKit + +final class PhoneCertificationViewModel: ViewModelType { + + struct Input { + let viewWillAppear: Driver + let phoneNum: Driver + let clearBtn: Driver + let verifyBtn: Driver + let codeInput: Driver + let finishAnimationTrigger: Driver + } + + struct Output { + let phoneNum: Driver + let validate: Driver + let error: Driver + let clearButtonTapped: Driver + let viewStatus: Driver + let certificateSuccess: Driver + let certificateFailuer: Driver + let timeStampLabel: Driver + let timeLabelTextColor: Driver + } + + weak var delegate: AuthCoordinatingActionDelegate? + private let useCase: AuthUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface + + private var disposeBag = DisposeBag() + + init(useCase: AuthUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + + let errorTracker = PublishRelay() + + let phoneNum = input.phoneNum + .debounce(.milliseconds(300)) + + let validate = phoneNum + .map { $0.phoneNumValidation() } + + let clearButtonTapped = input.clearBtn + .map { "" }.asDriver() + + let response = input.verifyBtn + .throttle(.milliseconds(500), latest: false) + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, phoneNum in + owner.useCase.certificate(phoneNumber: phoneNum) + .catch { certificateError in + errorTracker.accept(certificateError) + return .error(certificateError) + } + }.asDriver(onErrorDriveWith: .empty()) + + let authNumber = response + .map { AuthCodeWithTimeStamp(authCode: $0) } + + let viewStatus = authNumber.map { _ in ViewType.authCode } + .startWith(.phoneNumber) + + let certificateResult = input.codeInput + .distinctUntilChanged() + .filter { $0.count == 6 } + .withLatestFrom(authNumber) { inputCode, authNumber -> Bool in + guard authNumber.isAvailableCode() else { + return false + } + return inputCode == "\(authNumber.authCode)" + } + .asDriver(onErrorJustReturn: false) + + let certificateSuccess = certificateResult.filter { $0 == true } + + let checkUserExists = certificateSuccess + .withLatestFrom(phoneNum) + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, phoneNum in + owner.useCase.checkUserExists(phoneNumber: phoneNum) + .asObservable() + .catch { networkError in + errorTracker.accept(networkError) + return .empty() + } + }.asDriverOnErrorJustEmpty() + + let userInfo = certificateSuccess + .withLatestFrom(phoneNum) + .asObservable() + .withUnretained(self) + .flatMapLatest({ owner, phoneNum in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: phoneNum)) + }) + .asDriverOnErrorJustEmpty() + + checkUserExists + .withLatestFrom(Driver.combineLatest(userInfo, phoneNum) { userinfo, phoneNum in + var mutable = userinfo + mutable.phoneNumber = phoneNum + return mutable + }) + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + }.disposed(by: disposeBag) + + + let timer = authNumber + .flatMap { authNumber in + return Observable.interval(.seconds(1), scheduler: MainScheduler.instance) + .filter { _ in authNumber.isAvailableCode() } + .take(until: certificateResult.filter{ $0 == true } + .asObservable() + ) + .asDriver(onErrorDriveWith: Driver.empty()) + } + + let timeLabelStr = timer + .withLatestFrom(authNumber) { _, authNumber in + authNumber.timeString + } + + let timerLabelColor = timer + .withLatestFrom(authNumber) { _, authNumber in + if authNumber.isAvailableCode() { + return DSKitAsset.Color.neutral50 + } else { + return DSKitAsset.Color.error + } + } + + let isSignUp = Driver.zip(input.finishAnimationTrigger, checkUserExists) { $1 } + + isSignUp.filter { $0.isSignUp == true } + .withLatestFrom(phoneNum) { _, phoneNum in phoneNum } + .drive(with: self) { owner, phoneNum in + owner.userInfoUseCase.savePhoneNumber(phoneNum) + } + .disposed(by: disposeBag) + + isSignUp.filter { $0.isSignUp == true } + .withLatestFrom(phoneNum) { _, phoneNum in phoneNum } + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, phoneNum in + owner.useCase.login(phoneNumber: phoneNum, deviceKey: "device_key") + .asObservable() + .catch { error in + errorTracker.accept(error) + return .empty() + } + } + .asDriverOnErrorJustEmpty() + .drive(with: self) { owner, _ in + owner.delegate?.invoke(.toMain) + }.disposed(by: disposeBag) + + isSignUp.filter { $0.isSignUp == false } + .withLatestFrom(phoneNum) + .drive(with: self, onNext: { owner, phoneNum in + owner.delegate?.invoke(.toSignUp(phoneNumber: phoneNum)) + }) + .disposed(by: disposeBag) + + return Output( + phoneNum: phoneNum, + validate: validate, + error: errorTracker.asDriverOnErrorJustEmpty(), + clearButtonTapped: clearButtonTapped, + viewStatus: viewStatus, + certificateSuccess: certificateSuccess, + certificateFailuer: certificateResult.filter { $0 == false }, + timeStampLabel: timeLabelStr, + timeLabelTextColor: timerLabelColor + ) + } +} + +// MARK: Test Code +extension PhoneCertificationViewModel { + + enum ViewType { + case phoneNumber + case authCode + } + + struct AuthCodeWithTimeStamp { + let authCode: Int + let timeStamp = Date.now + var timeString: String { + let timeInterval = timeStamp.timeIntervalSinceNow + let min = abs(Int(timeInterval.truncatingRemainder(dividingBy: 3600)) / 60) + let sec = abs(Int(timeInterval.truncatingRemainder(dividingBy: 60))) + return String(format: "%02d:%02d", min, sec) + } + init(authCode: Int) { + self.authCode = authCode + } + + func isAvailableCode() -> Bool { + let timeInterval = timeStamp.timeIntervalSinceNow + let sec = Int(timeInterval) + return abs(sec) <= 180 + } + } +} diff --git a/Projects/Features/Auth/Src/AuthRoot/Certificate/SubView/SuccessCertificationView.swift b/Projects/Features/Auth/Src/AuthRoot/Certificate/SubView/SuccessCertificationView.swift new file mode 100644 index 00000000..7ed087bd --- /dev/null +++ b/Projects/Features/Auth/Src/AuthRoot/Certificate/SubView/SuccessCertificationView.swift @@ -0,0 +1,67 @@ +// +// SuccessCertificationView.swift +// DSKit +// +// Created by Hoo's MacBookPro on 2023/08/15. +// + +import UIKit + +import DSKit +import Lottie + +final class SuccessCertificationView: UIView { + + private lazy var backCardView = UIView().then { + $0.layer.cornerRadius = 12 + $0.backgroundColor = DSKitAsset.Color.neutral600.color + } + + private lazy var animationView = LottieAnimationView(animation: AnimationAsset.authSuccess.animation) + + private lazy var titleLabel = UILabel().then { + $0.font = .thtH2B + $0.textColor = DSKitAsset.Color.neutral50.color + $0.text = "핸드폰 번호 인증 완료" + } + + init() { + super.init(frame: .zero) + self.makeUI() + } + + func makeUI() { + self.backgroundColor = DSKitAsset.Color.DimColor.signUpDim.color + + self.addSubview(backCardView) + + [animationView, titleLabel] + .forEach { backCardView.addSubview($0) } + + backCardView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.equalToSuperview().multipliedBy(0.795) + $0.height.equalToSuperview().multipliedBy(0.536) + } + + animationView.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.top.equalToSuperview().offset(69) + $0.height.equalToSuperview().multipliedBy(0.469) + $0.width.equalToSuperview().multipliedBy(0.852) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(animationView.snp.bottom).offset(34) + $0.centerX.equalToSuperview() + } + } + + func animationPlay(completion: @escaping () -> Void) { + self.animationView.play { _ in completion() } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/SignUpRootViewController.swift b/Projects/Features/Auth/Src/AuthRoot/Root/AuthRootViewController.swift similarity index 55% rename from Projects/Features/SignUp/Src/SignUpRoot/SignUpRootViewController.swift rename to Projects/Features/Auth/Src/AuthRoot/Root/AuthRootViewController.swift index d4938445..54ebf9ae 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/SignUpRootViewController.swift +++ b/Projects/Features/Auth/Src/AuthRoot/Root/AuthRootViewController.swift @@ -10,8 +10,10 @@ import UIKit import DSKit -final class SignUpRootViewController: TFBaseViewController { - private lazy var buttonStackView: UIStackView = UIStackView().then { +import AuthInterface + +final class AuthRootViewController: TFBaseViewController { + private lazy var buttonStackView = UIStackView().then { $0.axis = .vertical $0.spacing = 16 } @@ -28,14 +30,10 @@ final class SignUpRootViewController: TFBaseViewController { $0.contentMode = .scaleAspectFit } - var viewModel: SignUpRootViewModel! - - override func viewDidLoad() { - super.viewDidLoad() - setupAccessibilityIdentifier() - } + var viewModel: AuthRootViewModel! override func makeUI() { + view.backgroundColor = DSKitAsset.Color.neutral700.color view.addSubview(signitureImageView) signitureImageView.snp.makeConstraints { $0.centerX.equalToSuperview() @@ -43,6 +41,11 @@ final class SignUpRootViewController: TFBaseViewController { .inset(view.bounds.height * 0.162) $0.height.equalTo(180) } + + signitureImageView.transform = CGAffineTransform(translationX: 0, y: 60) + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseOut) { + self.signitureImageView.transform = .identity + } self.view.addSubview(buttonStackView) self.buttonStackView.snp.makeConstraints { @@ -62,29 +65,17 @@ final class SignUpRootViewController: TFBaseViewController { } override func bindViewModel() { - let input = SignUpRootViewModel.Input( - phoneBtn: startPhoneBtn.rx.tap.asDriver(), - kakaoBtn: startKakaoButton.rx.tap.asDriver(), - googleBtn: startGoogleBtn.rx.tap.asDriver(), - naverBtn: startNaverBtn.rx.tap.asDriver() + let buttonTap = Driver.merge( + startPhoneBtn.rx.tap.asDriver().map { return SNSType.normal }, + startKakaoButton.rx.tap.asDriver().map { return SNSType.kakao }, + startGoogleBtn.rx.tap.asDriver().map { SNSType.google }, + startNaverBtn.rx.tap.asDriver().map { SNSType.naver } ) - let output = viewModel.transform(input: input) - } -} - -extension SignUpRootViewController { + let input = AuthRootViewModel.Input( + buttonTap: buttonTap + ) - private func setupAccessibilityIdentifier() { - startPhoneBtn.accessibilityIdentifier = AccessibilityIdentifier.phoneBtn - startKakaoButton.accessibilityIdentifier = AccessibilityIdentifier.kakoBtn - startNaverBtn.accessibilityIdentifier = AccessibilityIdentifier.naverBtn - startGoogleBtn.accessibilityIdentifier = AccessibilityIdentifier.googleBtn + let output = viewModel.transform(input: input) } } - -//struct SignUpRootViewControllerPreview: PreviewProvider { -// static var previews: some View { -// SignUpRootViewController(viewModel: SignUpRootViewModel(navigator: SignUpNavigator(controller: UINavigationController()))).toPreView() -// } -//} diff --git a/Projects/Features/Auth/Src/AuthRoot/Root/AuthRootViewModel.swift b/Projects/Features/Auth/Src/AuthRoot/Root/AuthRootViewModel.swift new file mode 100644 index 00000000..be2c7309 --- /dev/null +++ b/Projects/Features/Auth/Src/AuthRoot/Root/AuthRootViewModel.swift @@ -0,0 +1,38 @@ +// +// SignUpViewModel.swift +// Falling +// +// Created by Hoo's MacBookPro on 2023/07/22. +// + +import Foundation + +import RxSwift +import RxCocoa +import AuthInterface + +import Core + +final class AuthRootViewModel: ViewModelType { + struct Input { + let buttonTap: Driver + } + + struct Output { + + } + + weak var delegate: AuthCoordinatingActionDelegate? + + var disposeBag: DisposeBag = DisposeBag() + + func transform(input: Input) -> Output { + input.buttonTap + .drive(with: self, onNext: { owner, sns in + owner.delegate?.invoke(.tologinType(sns)) + }) + .disposed(by: disposeBag) + + return Output() + } +} diff --git a/Projects/Features/Auth/Src/AuthRoot/Root/SubView/TFLogginButton.swift b/Projects/Features/Auth/Src/AuthRoot/Root/SubView/TFLogginButton.swift new file mode 100644 index 00000000..1db95313 --- /dev/null +++ b/Projects/Features/Auth/Src/AuthRoot/Root/SubView/TFLogginButton.swift @@ -0,0 +1,89 @@ +// +// LoginButton.swift +// Falling +// +// Created by Hoo's MacBookPro on 2023/07/22. +// + +import UIKit + +import DSKit + +enum TFLoginButtonType { + case phone + case kakao + case google + case naver + + var title: String { + switch self { + case .phone: + return "핸드폰 번호로 시작하기" + case .kakao: + return "카카오톡으로 시작하기" + case .google: + return "구글로 시작하기" + case .naver: + return "네이버로 시작하기" + } + } + + var backGroundColor: UIColor { + switch self { + case .phone: + return DSKitAsset.Color.neutral900.color + case .kakao: + return .yellow +// return .kakaoPrimary + case .google: + return DSKitAsset.Color.neutral50.color + case .naver: + return .blue +// return .naverPrimary + } + } + + var titleColor: UIColor { + switch self { + case .phone, .naver: + return DSKitAsset.Color.neutral50.color + case .kakao, .google: + return DSKitAsset.Color.neutral900.color + } + } + +// var icon: UIImage { +// switch self { +// case .phone: +// return Icon.Profile.pin +// case .kakao: +// return Icon.Profile.pin +// case .google: +// return Icon.Profile.pin +// case .naver: +// return Icon.Profile.pin +// } +// } +} + +final class TFLoginButton: UIButton { + let btnType: TFLoginButtonType + + init(btnType: TFLoginButtonType) { + self.btnType = btnType + super.init(frame: .zero) + + setTitle(btnType.title, for: .normal) +// setImage(btnType.icon, for: .normal) + setTitleColor(btnType.titleColor, for: .normal) + backgroundColor = btnType.backGroundColor + titleLabel?.font = .thtSubTitle1Sb + imageEdgeInsets.right = 52 + layer.cornerRadius = 26 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Projects/Features/Auth/Src/Coordinator/AuthBuilder.swift b/Projects/Features/Auth/Src/Coordinator/AuthBuilder.swift new file mode 100644 index 00000000..083e039b --- /dev/null +++ b/Projects/Features/Auth/Src/Coordinator/AuthBuilder.swift @@ -0,0 +1,27 @@ +// +// SignUpBuilder.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation +import Core + +import SignUpInterface +import AuthInterface + +public final class AuthBuilder: AuthBuildable { + private let signUpBuilable: SignUpBuildable + + public init(signUpBuilable: SignUpBuildable) { + self.signUpBuilable = signUpBuilable + } + + public func build() -> AuthCoordinating { + let rootViewController = NavigationViewControllable() + let coordinator = AuthCoordinator(signUpBuildable: signUpBuilable, viewControllable: rootViewController) + + return coordinator + } +} diff --git a/Projects/Features/Auth/Src/Coordinator/AuthCoordinator.swift b/Projects/Features/Auth/Src/Coordinator/AuthCoordinator.swift new file mode 100644 index 00000000..2b7be3d2 --- /dev/null +++ b/Projects/Features/Auth/Src/Coordinator/AuthCoordinator.swift @@ -0,0 +1,123 @@ +// +// AuthCoordinator.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +import Core +import AuthInterface +import SignUpInterface + +protocol AuthCoordinatingActionDelegate: AnyObject { + func invoke(_ action: AuthCoordinatingAction) +} + +public final class AuthCoordinator: BaseCoordinator, AuthCoordinating { + @Injected private var authUseCase: AuthUseCaseInterface + @Injected private var userInfoUseCase: UserInfoUseCaseInterface + + private let signUpBuildable: SignUpBuildable + private var signUpCoordinator: SignUpCoordinating? + public weak var delegate: AuthCoordinatingDelegate? + + public init(signUpBuildable: SignUpBuildable, viewControllable: ViewControllable) { + self.signUpBuildable = signUpBuildable + super.init(viewControllable: viewControllable) + } + + public override func start() { + replaceWindowRootViewController(rootViewController: self.viewControllable) + + launchFlow() + } + + + // MARK: Launch Screen + public func launchFlow() { + // TODO: Launch Screen 에서 가입/인증/메인 분기 처리 + + var needAuth = true + if needAuth { + rootFlow() + } + } + + // MARK: 인증 토큰 재발급 또는 가입 시 + public func rootFlow() { + let viewModel = AuthRootViewModel() + viewModel.delegate = self + + let viewController = AuthRootViewController() + viewController.viewModel = viewModel + + self.viewControllable.setViewControllers([viewController]) + } + + public func phoneNumberFlow() { + let viewModel = PhoneCertificationViewModel(useCase: authUseCase, userInfoUseCase: self.userInfoUseCase) + viewModel.delegate = self + + let viewController = PhoneCertificationViewController(viewModel: viewModel) + + self.viewControllable.pushViewController(viewController, animated: true) + } + + public func snsFlow(type: SNSType) { + switch type { + case .normal: + phoneNumberFlow() + case .kakao: + print(type.rawValue) + case .naver: + print(type.rawValue) + case .google: + print(type.rawValue) + case .apple: + print(type.rawValue) + } + } +} + +extension AuthCoordinator: AuthCoordinatingActionDelegate { + func invoke(_ action: AuthCoordinatingAction) { + switch action { + case let .tologinType(snsType): + snsFlow(type: snsType) + case let .toSignUp(phoneNum): + attachSignUpCoordiantor() + case .toMain: + self.delegate?.detachAuth(self) + } + } +} + +// MARK: SignUpCoordinator + +extension AuthCoordinator { + func attachSignUpCoordiantor() { + if self.signUpCoordinator != nil { return } + let coordinator = self.signUpBuildable.build() + coordinator.delegate = self + self.attachChild(coordinator) + self.signUpCoordinator = coordinator + + coordinator.start() + } + func detachSignUpCoordinator() { + guard let coordinator = self.signUpCoordinator else { return } + self.detachChild(coordinator) + self.signUpCoordinator = nil + } +} + +extension AuthCoordinator: SignUpCoordinatorDelegate { + public func detachSignUp(_ coordinator: Coordinator) { + self.detachSignUpCoordinator() + + self.delegate?.detachAuth(self) + } +} + diff --git a/Projects/Features/Auth/Src/Coordinator/LaunchBuilder.swift b/Projects/Features/Auth/Src/Coordinator/LaunchBuilder.swift new file mode 100644 index 00000000..b793f464 --- /dev/null +++ b/Projects/Features/Auth/Src/Coordinator/LaunchBuilder.swift @@ -0,0 +1,20 @@ +// +// LaunchBuilder.swift +// Auth +// +// Created by Kanghos on 6/4/24. +// + +import Foundation +import Core +import AuthInterface + +public final class LaunchBuilder: LaunchBuildable { + + public init() { } + + public func build(rootViewControllable: ViewControllable) -> AuthLaunchCoordinating { + let coordinator = AuthLaunchCoordinator(viewControllable: rootViewControllable) + return coordinator + } +} diff --git a/Projects/Features/Auth/Src/Coordinator/LaunchCoordinator.swift b/Projects/Features/Auth/Src/Coordinator/LaunchCoordinator.swift new file mode 100644 index 00000000..0300602b --- /dev/null +++ b/Projects/Features/Auth/Src/Coordinator/LaunchCoordinator.swift @@ -0,0 +1,39 @@ +// +// LaunchCoordinator.swift +// AuthDemo +// +// Created by Kanghos on 6/4/24. +// + +import UIKit +import Core +import DSKit +import AuthInterface +import SignUpInterface + +public final class AuthLaunchCoordinator: BaseCoordinator, AuthLaunchCoordinating { + @Injected private var useCase: AuthUseCaseInterface + @Injected private var userInfoUseCase: UserInfoUseCaseInterface + public weak var delegate: LaunchCoordinatingDelegate? + + public override func start() { + launchFlow() + } + + public func launchFlow() { + let vm = LauncherViewModel(userInfoUseCase: self.userInfoUseCase, useCase: self.useCase) + let vc = TFAuthLauncherViewController(viewModel: vm) + vm.delegate = self + self.viewControllable.pushViewController(vc, animated: true) + } +} + +extension AuthLaunchCoordinator: LauncherDelegate { + public func needAuth() { + self.delegate?.finishFlow(self, .needAuth) + } + + public func toMain() { + self.delegate?.finishFlow(self, .toMain) + } +} diff --git a/Projects/Features/Auth/Src/File.swift b/Projects/Features/Auth/Src/File.swift deleted file mode 100644 index 436c7e7d..00000000 --- a/Projects/Features/Auth/Src/File.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// AuthInterface -// -// Created by Hoo's MacBookPro on 12/3/23. -// - -import Foundation diff --git a/Projects/Features/Auth/Src/Launcher/LauncherViewModel.swift b/Projects/Features/Auth/Src/Launcher/LauncherViewModel.swift new file mode 100644 index 00000000..5c6b3c7a --- /dev/null +++ b/Projects/Features/Auth/Src/Launcher/LauncherViewModel.swift @@ -0,0 +1,75 @@ +// +// LauncherViewModel.swift +// AuthDemo +// +// Created by Kanghos on 6/4/24. +// + +import Foundation + +import AuthInterface +import SignUpInterface +import Core + +import RxSwift +import RxCocoa + +protocol LauncherDelegate: AnyObject { + func needAuth() + func toMain() +} + +public final class LauncherViewModel: ViewModelType { + private var disposeBag = DisposeBag() + private let userInfoUseCase: UserInfoUseCaseInterface + private let useCase: AuthUseCaseInterface + weak var delegate: LauncherDelegate? + + public struct Input { + let viewDidLoad: Driver + } + + public struct Output { + let state: Driver + } + + public init(userInfoUseCase: UserInfoUseCaseInterface, useCase: AuthUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + self.useCase = useCase + } + + public func transform(input: Input) -> Output { + + let phoneNumber = userInfoUseCase.fetchPhoneNumber() + .catchAndReturn("") + .asDriver(onErrorJustReturn: "") + + let needAuth = input.viewDidLoad + .withLatestFrom(phoneNumber) + .map { $0.isEmpty } + + needAuth + .filter { $0 } + .drive(with: self) { owner, needAuth in + owner.delegate?.needAuth() + }.disposed(by: disposeBag) + + needAuth + .filter { !$0 } + .asObservable() + .withLatestFrom(phoneNumber) + .withUnretained(self) + .flatMap { owner, phoneNum in + owner.useCase.login(phoneNumber: phoneNum, deviceKey: "device") + .asObservable() + .catch { error in + TFLogger.dataLogger.error("\(error.localizedDescription)") + return .empty() + } + }.subscribe(with: self) { owner, _ in + owner.delegate?.toMain() + }.disposed(by: disposeBag) + + return Output(state: Driver.just(())) + } +} diff --git a/Projects/Features/Auth/Src/Launcher/TFAuthLauncherViewController.swift b/Projects/Features/Auth/Src/Launcher/TFAuthLauncherViewController.swift new file mode 100644 index 00000000..f0abe630 --- /dev/null +++ b/Projects/Features/Auth/Src/Launcher/TFAuthLauncherViewController.swift @@ -0,0 +1,33 @@ +// +// TFLauncherViewController.swift +// AuthDemo +// +// Created by Kanghos on 6/4/24. +// + +import DSKit +import Foundation + +public final class TFAuthLauncherViewController: TFLaunchViewController { + private let viewModel: LauncherViewModel + + public init(viewModel: LauncherViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + public override func bindViewModel() { + let input = LauncherViewModel.Input( + viewDidLoad: self.rx.viewDidAppear.asDriver().delay(.seconds(1)).map { _ in } + ) + let output = viewModel.transform(input: input) + + output.state + .drive() + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/Auth/Src/SubView/TFTextButton.swift b/Projects/Features/Auth/Src/SubView/TFTextButton.swift new file mode 100644 index 00000000..2b0bdd01 --- /dev/null +++ b/Projects/Features/Auth/Src/SubView/TFTextButton.swift @@ -0,0 +1,45 @@ +// +// TFTextButton.swift +// Falling +// +// Created by Hoo's MacBookPro on 2023/08/13. +// + +import UIKit + +import DSKit + +final class TFTextButton: UIButton { + let title: String + + init(title: String) { + self.title = title + super.init(frame: .zero) + makeView() + } + + func makeView() { + let attributedString = NSMutableAttributedString(string: title) + attributedString.addAttribute(.underlineStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSRange(location: 0, length: title.count)) + + attributedString.addAttribute(.font, + value: UIFont.thtP2M, + range: NSRange(location: 0, length: title.count)) + + attributedString.addAttribute(.underlineColor, + value: DSKitAsset.Color.neutral400.color, + range: NSRange(location: 0, length: title.count)) + + attributedString.addAttribute(.foregroundColor, + value: DSKitAsset.Color.neutral400.color, + range: NSRange(location: 0, length: title.count)) + + setAttributedTitle(attributedString, for: .normal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Projects/Features/Auth/Src/UseCase/AuthUseCase.swift b/Projects/Features/Auth/Src/UseCase/AuthUseCase.swift new file mode 100644 index 00000000..cb850a01 --- /dev/null +++ b/Projects/Features/Auth/Src/UseCase/AuthUseCase.swift @@ -0,0 +1,55 @@ +// +// AuthUseCase.swift +// AuthInterface +// +// Created by Kanghos on 5/27/24. +// + +import Foundation +import AuthInterface +import RxSwift + +public final class AuthUseCase: AuthUseCaseInterface { + private let repository: AuthRepositoryInterface + private let tokenStore: TokenStore + + public init(authRepository: AuthRepositoryInterface, tokenStore: TokenStore) { + self.repository = authRepository + self.tokenStore = tokenStore + } + public func certificate(phoneNumber: String) -> RxSwift.Single { + repository.certificate(phoneNumber: phoneNumber) + } + + public func checkUserExists(phoneNumber: String) -> Single { + return repository.checkUserExist(phoneNumber: phoneNumber) + } + + public func login(phoneNumber: String, deviceKey: String) -> Single { + return repository.login(phoneNumber: phoneNumber, deviceKey: deviceKey) + .flatMap { [weak self] token in + self?.tokenStore.saveToken(token: token) + return .just(()) + } + } + + public func loginSNS(_ request: AuthInterface.UserSNSLoginRequest) -> Single { + return repository.loginSNS(request) + .flatMap { [weak self] token in + self?.tokenStore.saveToken(token: token) + return .just(()) + } + } + + // TODO: token을 어디서 집어넣을 지 생각해볼 것 e,g Authenticator + public func refresh() -> Single { + guard let token = try? tokenStore.getToken() else { + return .error(AuthError.invalidToken) + } + return repository.refresh(token) + .flatMap { [weak self] token in + self?.tokenStore.saveToken(token: token) + return .just(()) + } + } +} diff --git a/Projects/Features/Auth/Src/Util/Regex+Util.swift b/Projects/Features/Auth/Src/Util/Regex+Util.swift new file mode 100644 index 00000000..37083451 --- /dev/null +++ b/Projects/Features/Auth/Src/Util/Regex+Util.swift @@ -0,0 +1,23 @@ +// +// Regex+Util.swift +// AuthInterface +// +// Created by Kanghos on 5/8/24. +// + +import Foundation + +enum Regex: String { + case phoneNum = "^01[0-1,7][0-9]{7,8}$" + case email = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" +} + +extension String { + func phoneNumValidation() -> Bool { + return (self.range(of: Regex.phoneNum.rawValue, options: .regularExpression) != nil) + } + + func emailValidation() -> Bool { + return (self.range(of: Regex.email.rawValue, options: .regularExpression) != nil) + } +} diff --git a/Projects/Features/Falling/Src/Home/FallingHomeViewController.swift b/Projects/Features/Falling/Src/Home/FallingHomeViewController.swift index 25017041..2b521b3e 100644 --- a/Projects/Features/Falling/Src/Home/FallingHomeViewController.swift +++ b/Projects/Features/Falling/Src/Home/FallingHomeViewController.swift @@ -206,7 +206,6 @@ final class FallingHomeViewController: TFBaseViewController { dimColor: DSKitAsset.Color.clear.color, topActionCompletion: { blockButtonTapTrigger.accept(()) - owner.homeView.makeToast("차단하기가 완료되었습니다. 해당 사용자와\n서로 차단되며 설정에서 확인 가능합니다.", duration: 3.0, position: .bottom) }, bottomActionCompletion: { timerActiveRelay.accept(true) }, diff --git a/Projects/Features/Like/Interface/Src/Coordinator/LikeCoordinating.swift b/Projects/Features/Like/Interface/Src/Coordinator/LikeCoordinating.swift index d34e7565..b5109f76 100644 --- a/Projects/Features/Like/Interface/Src/Coordinator/LikeCoordinating.swift +++ b/Projects/Features/Like/Interface/Src/Coordinator/LikeCoordinating.swift @@ -13,12 +13,18 @@ public protocol LikeCoordinatorDelegate: AnyObject { func test(_ coordinator: Coordinator) } + +public protocol LikeProfileListener: AnyObject { + func likeProfileDidTapReject(_ like: Like) + func likeProfileDidTapChat(_ like: Like) +} + public protocol LikeCoordinating: Coordinator { var delegate: LikeCoordinatorDelegate? { get set } func homeFlow() func chatRoomFlow() - func profileFlow(_ item: Like) + func profileFlow(_ item: Like, listener: LikeProfileListener) } public enum LikeCoordinatorAction { diff --git a/Projects/Features/Like/Src/Coordinator/LikeBuilder.swift b/Projects/Features/Like/Src/Coordinator/LikeBuilder.swift index 507818c4..fb2e237a 100644 --- a/Projects/Features/Like/Src/Coordinator/LikeBuilder.swift +++ b/Projects/Features/Like/Src/Coordinator/LikeBuilder.swift @@ -15,7 +15,8 @@ public final class LikeBuilder: LikeBuildable { public init() { } public func build(rootViewControllable: ViewControllable) -> LikeCoordinating { - let coordinator = LikeCoordinator(viewControllable: rootViewControllable) + let buildable = MockChatRoomBuilder() + let coordinator = LikeCoordinator(chatRoomBuildable: buildable, viewControllable: rootViewControllable) return coordinator } diff --git a/Projects/Features/Like/Src/Coordinator/LikeCoordinator.swift b/Projects/Features/Like/Src/Coordinator/LikeCoordinator.swift index 441f4b01..8fe43c49 100644 --- a/Projects/Features/Like/Src/Coordinator/LikeCoordinator.swift +++ b/Projects/Features/Like/Src/Coordinator/LikeCoordinator.swift @@ -10,15 +10,54 @@ import Foundation import LikeInterface import Core +enum LikeCoordinatorAction { + case presentProfile(like: Like, listener: LikeProfileListener) + case pushChatRoom(id: String) + case dismissProfile +} + +protocol LikeCoordinatorActionDelegate: AnyObject { + func invoke(_ action: LikeCoordinatorAction) +} + +// MARK: Using Other Feature Coordinator +// step 1. get buildable when init +// step 2. build coordinator when need, and assign optional variable -> attachMethod +// step 3. release when coordinator finish, bc, optimize memory -> detachMethod + public final class LikeCoordinator: BaseCoordinator, LikeCoordinating { @Injected var likeUseCase: LikeUseCaseInterface + private let chatRoomBuildable: ChatRoomBuildable + private var chatRoomCoordinator: ChatRoomCoordinating? + public weak var delegate: LikeCoordinatorDelegate? - + public override func start() { homeFlow() } + init(chatRoomBuildable: ChatRoomBuildable, viewControllable: ViewControllable) { + self.chatRoomBuildable = chatRoomBuildable + super.init(viewControllable: viewControllable) + } + + func attachChatRoomCoordinator() { + if self.chatRoomCoordinator != nil { return } + let coordinator = chatRoomBuildable.build(rootViewControllable: self.viewControllable, listener: self) + coordinator.chatRoomFlow() + self.attachChild(coordinator) + self.chatRoomCoordinator = coordinator + } + + func detachChatRoomCoordinator() { + guard let coordinator = self.chatRoomCoordinator else { return } + self.viewControllable.popViewController(animated: true) + + self.detachChild(coordinator) + self.chatRoomCoordinator = nil + } + public func homeFlow() { let viewModel = LikeHomeViewModel(likeUseCase: likeUseCase) viewModel.delegate = self @@ -27,41 +66,44 @@ public final class LikeCoordinator: BaseCoordinator, LikeCoordinating { self.viewControllable.setViewControllers([viewController]) } - + public func chatRoomFlow() { TFLogger.dataLogger.info("ChatRoom!") + attachChatRoomCoordinator() } - - public func profileFlow(_ item: Like) { + + public func profileFlow(_ item: Like, listener: LikeProfileListener) { let viewModel = LikeProfileViewModel(likeUseCase: likeUseCase, likItem: item) + viewModel.listener = listener viewModel.delegate = self let viewController = LikeProfileViewController(viewModel: viewModel) - - self.viewControllable.pushViewController(viewController, animated: true) + viewController.modalPresentationStyle = .currentContext + self.viewControllable.present(viewController, animated: true) } } -extension LikeCoordinator: LikeHomeDelegate { - func toProfile(like: LikeInterface.Like) { - profileFlow(like) +extension LikeCoordinator: LikeCoordinatorActionDelegate { + func invoke(_ action: LikeCoordinatorAction) { + switch action { + case let .presentProfile(like, listener): + profileFlow(like, listener: listener) + case .pushChatRoom(let id): + chatRoomFlow() + case .dismissProfile: + dismissProfile() + } } - - func toChatRoom(userID: String) { - chatRoomFlow() + + func dismissProfile() { + self.viewControllable.dismiss() } } -extension LikeCoordinator: LikeProfileDelegate { - func selectNextTime(userUUID: String) { - viewControllable.popViewController(animated: true) - } - - func selectLike(userUUID: String) { - viewControllable.popViewController(animated: true) - } - - func toList() { - viewControllable.popViewController(animated: true) +extension LikeCoordinator: ChatRoomCoordinatorDelegate { + func didFinishChatRoomCoordinator(_ coordinator: Coordinator?) { + if let coordinator { + detachChatRoomCoordinator() + } } } diff --git a/Projects/Features/Like/Src/Coordinator/MockChatCoordinator.swift b/Projects/Features/Like/Src/Coordinator/MockChatCoordinator.swift new file mode 100644 index 00000000..4bbdaeac --- /dev/null +++ b/Projects/Features/Like/Src/Coordinator/MockChatCoordinator.swift @@ -0,0 +1,62 @@ +// +// MockChatCoordinator.swift +// Like +// +// Created by Kanghos on 2024/05/03. +// + +import Foundation +import Core + +protocol ChatRoomCoordinating: Coordinator { + var delegate: ChatRoomCoordinatorDelegate? { get set } + func chatRoomFlow() + func profileFLow() + func photoselect() + func logout() +} + +// MARK: Communicate Parent Coordinator +protocol ChatRoomCoordinatorDelegate: AnyObject { + func didFinishChatRoomCoordinator(_ coordinator: Coordinator?) // release Coordinator +} + +enum ChatRoomCoordinatorAction { + case finish +} + +protocol ChatRoomActionDelegate: AnyObject { + func invoke(_ action: ChatRoomCoordinatorAction) +} + +class MockChatCoordinator: BaseCoordinator, ChatRoomCoordinating { + func profileFLow() { + + } + + func photoselect() { + + } + + func logout() { + + } + + weak var delegate: ChatRoomCoordinatorDelegate? + func chatRoomFlow() { + let vc = MockChatRoomViewController() + vc.delegate = self + + self.viewControllable.pushViewController(vc, animated: true) + } +} + +// MARK: process ChatRoom navigation action +extension MockChatCoordinator: ChatRoomActionDelegate { + func invoke(_ action: ChatRoomCoordinatorAction) { + switch action { + case .finish: + self.delegate?.didFinishChatRoomCoordinator(self) + } + } +} diff --git a/Projects/Features/Like/Src/Coordinator/MockChatRoomBuilder.swift b/Projects/Features/Like/Src/Coordinator/MockChatRoomBuilder.swift new file mode 100644 index 00000000..4d3c210c --- /dev/null +++ b/Projects/Features/Like/Src/Coordinator/MockChatRoomBuilder.swift @@ -0,0 +1,23 @@ +// +// MockChatRoomBuilder.swift +// Like +// +// Created by Kanghos on 2024/05/03. +// + +import Foundation +import Core + +protocol ChatRoomBuildable { + func build(rootViewControllable: ViewControllable, listener: ChatRoomCoordinatorDelegate) -> ChatRoomCoordinating +} + +class MockChatRoomBuilder: ChatRoomBuildable { + public init() { } + public func build(rootViewControllable: ViewControllable, listener: ChatRoomCoordinatorDelegate) -> ChatRoomCoordinating { + + let coordinator = MockChatCoordinator(viewControllable: rootViewControllable) + coordinator.delegate = listener + return coordinator + } +} diff --git a/Projects/Features/Like/Src/Coordinator/MockChatRoomViewController.swift b/Projects/Features/Like/Src/Coordinator/MockChatRoomViewController.swift new file mode 100644 index 00000000..11bca709 --- /dev/null +++ b/Projects/Features/Like/Src/Coordinator/MockChatRoomViewController.swift @@ -0,0 +1,47 @@ +// +// MockChatRoomViewController.swift +// Like +// +// Created by Kanghos on 2024/05/03. +// + +import UIKit + +import DSKit + +import RxSwift +import RxCocoa + +final class MockChatRoomViewController: TFBaseViewController { + + weak var delegate: ChatRoomActionDelegate? + + private let label: UILabel = { + let label = UILabel() + label.text = "Mock Chat Room" + label.font = .thtH1B + return label + }() + + private let backButton: UIBarButtonItem = .backButton + + override func makeUI() { + self.view.addSubview(label) + + label.center = self.view.center + } + + override func navigationSetting() { + super.navigationSetting() + + self.navigationItem.leftBarButtonItem = backButton + } + + override func bindViewModel() { + backButton.rx.tap + .asDriver() + .drive(with: self) { owner, _ in + owner.delegate?.invoke(.finish) + }.disposed(by: disposeBag) + } +} diff --git a/Projects/Features/Like/Src/Home/LikeHomeViewController.swift b/Projects/Features/Like/Src/Home/LikeHomeViewController.swift index 508a1f6c..a7d922bd 100644 --- a/Projects/Features/Like/Src/Home/LikeHomeViewController.swift +++ b/Projects/Features/Like/Src/Home/LikeHomeViewController.swift @@ -11,12 +11,6 @@ import Core import DSKit import LikeInterface -enum LikeCellButtonAction { - case reject(IndexPath) - case chat(IndexPath) - case profile(IndexPath) -} - public final class LikeHomeViewController: TFBaseViewController { private lazy var mainView = HeartListView() private var dataSource: DataSource! @@ -70,10 +64,6 @@ public final class LikeHomeViewController: TFBaseViewController { let output = viewModel.transform(input: input) - output.chatRoom - .drive() - .disposed(by: disposeBag) - output.heartList .drive(onNext: { [weak self] list in self?.mainView.emptyView.isHidden = !list.isEmpty @@ -90,10 +80,6 @@ public final class LikeHomeViewController: TFBaseViewController { self?.pagingDataSource(items) }).disposed(by: disposeBag) - output.profile - .drive() - .disposed(by: disposeBag) - output.reject .drive(onNext: { [weak self] item in self?.deleteItems(item) diff --git a/Projects/Features/Like/Src/Home/LikeHomeViewModel.swift b/Projects/Features/Like/Src/Home/LikeHomeViewModel.swift index 43787f17..dd173528 100644 --- a/Projects/Features/Like/Src/Home/LikeHomeViewModel.swift +++ b/Projects/Features/Like/Src/Home/LikeHomeViewModel.swift @@ -13,17 +13,13 @@ import LikeInterface import RxSwift import RxCocoa -protocol LikeHomeDelegate: AnyObject { - func toProfile(like: Like) - func toChatRoom(userID: String) -} - final class LikeHomeViewModel: ViewModelType { private let likeUseCase: LikeUseCaseInterface - - weak var delegate: LikeHomeDelegate? - var disposeBag: DisposeBag = DisposeBag() + weak var delegate: LikeCoordinatorActionDelegate? + + private var disposeBag: DisposeBag = DisposeBag() + private let signal = PublishSubject() init(likeUseCase: LikeUseCaseInterface) { self.likeUseCase = likeUseCase @@ -35,7 +31,17 @@ final class LikeHomeViewModel: ViewModelType { } } +enum LikeCellButtonAction { + case reject(IndexPath) + case chat(IndexPath) + case profile(IndexPath) +} + extension LikeHomeViewModel { + enum Action { + case removeItem(Like) + } + struct Input { let trigger: Driver let cellButtonAction: Driver @@ -44,8 +50,6 @@ extension LikeHomeViewModel { struct Output { let heartList: Driver<[Like]> - let chatRoom: Driver - let profile: Driver let reject: Driver let pagingList: Driver<[Like]> } @@ -59,35 +63,50 @@ extension LikeHomeViewModel { func transform(input: Input) -> Output { let currentCursor = BehaviorRelay(value: CursorInfo(nil, nil)) let snapshot = BehaviorRelay<[Like]>(value: []) + let navigateAction = PublishSubject() let refresh = input.trigger - .asObservable() - .flatMapLatest(weak: self, selector: { owner, _ in - owner.likeUseCase.fetchList(size: 100, lastTopicIndex: nil, lastLikeIndex: nil) - .map { - currentCursor.accept(CursorInfo($0.lastLikeIdx, $0.lastFallingTopicIdx)) - let initial = $0.likeList - snapshot.accept(initial) - return initial - } - }) - .asDriverOnErrorJustEmpty() + .flatMapLatest { [unowned self] _ in + self.likeUseCase.fetchList(size: 100, lastTopicIndex: nil, lastLikeIndex: nil) + .flatMap { info in + currentCursor.accept(CursorInfo(info.lastLikeIdx, info.lastFallingTopicIdx)) + let initial = info.likeList + snapshot.accept(initial) + return Observable.just(initial) + } + .asDriverOnErrorJustEmpty() + } let newPage = input.pagingTrigger .asObservable() .withLatestFrom(currentCursor) - .flatMapLatest(weak: self, selector: { owner, cursorInfo in + .withUnretained(self) + .flatMapLatest { owner, cursorInfo in owner.likeUseCase.fetchList(size: 100, lastTopicIndex: nil, lastLikeIndex: nil) - .map { - currentCursor.accept(CursorInfo($0.lastLikeIdx, $0.lastFallingTopicIdx)) - var mutable = snapshot.value - mutable.append(contentsOf: $0.likeList ) - snapshot.accept(mutable) - return mutable - } - }) + .map { + currentCursor.accept(CursorInfo($0.lastLikeIdx, $0.lastFallingTopicIdx)) + var mutable = snapshot.value + mutable.append(contentsOf: $0.likeList ) + snapshot.accept(mutable) + return mutable + } + } .asDriverOnErrorJustEmpty() + let rejectFromSignal = self.signal + .compactMap { action -> Like? in + if case let .removeItem(like) = action { + return like + } + return nil + }.map { like in + var mutable = snapshot.value + mutable.removeAll { $0 == like } + snapshot.accept(mutable) + return like + }.asDriverOnErrorJustEmpty() + + let reject = input.cellButtonAction .compactMap { action -> IndexPath? in if case let .reject(indexPath) = action { @@ -105,7 +124,7 @@ extension LikeHomeViewModel { return deleted } - let chatRoom = input.cellButtonAction + input.cellButtonAction .compactMap { action -> IndexPath? in if case let .chat(indexPath) = action { return indexPath @@ -114,14 +133,13 @@ extension LikeHomeViewModel { } .withLatestFrom(snapshot.asDriverOnErrorJustEmpty()) { indexPath, dataSource in - dataSource[indexPath.item] + dataSource[indexPath.item].userUUID } - .do(onNext: { [weak self] item in - self?.delegate?.toChatRoom(userID: item.userUUID) - }) - .map { _ in } + .map { .pushChatRoom(id: $0) } + .drive(navigateAction) + .disposed(by: disposeBag) - let profile = input.cellButtonAction + input.cellButtonAction .compactMap { action -> IndexPath? in if case let .profile(indexPath) = action { return indexPath @@ -132,16 +150,33 @@ extension LikeHomeViewModel { indexPath, dataSource in dataSource[indexPath.item] } - .do(onNext: { [weak self] item in - self?.delegate?.toProfile(like: item) - }).map { _ in } + .drive(with: self, onNext: { owner, like in + navigateAction.onNext(.presentProfile(like: like, listener: owner)) + } ) + .disposed(by: disposeBag) + + navigateAction + .subscribe(onNext: { [weak self] action in + self?.delegate?.invoke(action) + }) + .disposed(by: disposeBag) return Output( heartList: refresh, - chatRoom: chatRoom, - profile: profile, - reject: reject, + reject: Driver.merge(reject, rejectFromSignal), pagingList: newPage ) } } + +extension LikeHomeViewModel: LikeProfileListener { + + func likeProfileDidTapChat(_ like: Like) { + self.delegate?.invoke(.pushChatRoom(id: like.userUUID)) + } + + func likeProfileDidTapReject(_ like: Like) { + self.signal.onNext(.removeItem(like)) + // self.delegate?.invoke(.dismissProfile) + } +} diff --git a/Projects/Features/Like/Src/Profile/LikeProfileViewModel.swift b/Projects/Features/Like/Src/Profile/LikeProfileViewModel.swift index aee96ad9..6ddc021b 100644 --- a/Projects/Features/Like/Src/Profile/LikeProfileViewModel.swift +++ b/Projects/Features/Like/Src/Profile/LikeProfileViewModel.swift @@ -11,20 +11,14 @@ import DSKit import LikeInterface import Domain -protocol LikeProfileDelegate: AnyObject { - func selectNextTime(userUUID: String) - func selectLike(userUUID: String) - func toList() -} - final class LikeProfileViewModel: ViewModelType { private let likeUseCase: LikeUseCaseInterface private let likItem: Like private var disposeBag = DisposeBag() - weak var delegate: LikeProfileDelegate? - + weak var listener: LikeProfileListener? + weak var delegate: LikeCoordinatorActionDelegate? init(likeUseCase: LikeUseCaseInterface, likItem: Like) { self.likeUseCase = likeUseCase @@ -65,23 +59,25 @@ final class LikeProfileViewModel: ViewModelType { input.rejectTrigger .withLatestFrom(userIDSubject.asDriverOnErrorJustEmpty()) - .map { $0.likeIdx }.map(String.init) - .drive(with: self, onNext: { owner, uuid in - owner.delegate?.selectNextTime(userUUID: uuid) + .map { $0 } + .drive(with: self, onNext: { owner, like in + owner.listener?.likeProfileDidTapReject(like) + owner.delegate?.invoke(.dismissProfile) }) .disposed(by: disposeBag) input.likeTrigger .withLatestFrom(userIDSubject.asDriver(onErrorDriveWith: .empty())) - .map { $0.likeIdx }.map(String.init) - .drive(with: self, onNext: { owner, id in - owner.delegate?.selectLike(userUUID: id) + .map { $0 } + .drive(with: self, onNext: { owner, like in + owner.listener?.likeProfileDidTapChat(like) + owner.delegate?.invoke(.dismissProfile) }) .disposed(by: disposeBag) input.closeTrigger .drive(with: self, onNext: { owner, _ in - owner.delegate?.toList() + owner.delegate?.invoke(.dismissProfile) }) .disposed(by: disposeBag) diff --git a/Projects/Features/MyPage/Interface/Src/RepositoryInterface/MyPageRepositoryInterface.swift b/Projects/Features/MyPage/Interface/Src/RepositoryInterface/MyPageRepositoryInterface.swift index 91146505..a1d0a4aa 100644 --- a/Projects/Features/MyPage/Interface/Src/RepositoryInterface/MyPageRepositoryInterface.swift +++ b/Projects/Features/MyPage/Interface/Src/RepositoryInterface/MyPageRepositoryInterface.swift @@ -6,7 +6,9 @@ // import Foundation +import RxSwift +import RxCocoa public protocol MyPageRepositoryInterface { - func test() + } diff --git a/Projects/Features/MyPage/Project.swift b/Projects/Features/MyPage/Project.swift index f4c42098..1d325469 100644 --- a/Projects/Features/MyPage/Project.swift +++ b/Projects/Features/MyPage/Project.swift @@ -23,6 +23,7 @@ let project = Project( dependencies: [ .feature(interface: .MyPage), .feature(interface: .Auth), + .feature(interface: .SignUp), .dsKit ] ), diff --git a/Projects/Features/SignUp/Demo/Src/AppDelegate+Register.swift b/Projects/Features/SignUp/Demo/Src/AppDelegate+Register.swift new file mode 100644 index 00000000..b02c74ea --- /dev/null +++ b/Projects/Features/SignUp/Demo/Src/AppDelegate+Register.swift @@ -0,0 +1,43 @@ +// +// AppDelegate+Register.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +import Core + +import SignUp +import SignUpInterface +import AuthInterface +import Data + +extension AppDelegate { + var container: DIContainer { + DIContainer.shared + } + + func registerDependencies() { + + container.register( + interface: SignUpUseCaseInterface.self, + implement: { + SignUpUseCase( + repository: SignUpRepository(), + locationService: LocationService(), + kakaoAPIService: KakaoAPIService(), + contactService: ContactService() + ) + } + ) + + container.register( + interface: UserInfoUseCaseInterface.self, + implement: { + UserInfoUseCase(repository: UserDefaultUserInfoRepository()) + }) + } +} + diff --git a/Projects/Features/SignUp/Demo/Src/AppDelegate.swift b/Projects/Features/SignUp/Demo/Src/AppDelegate.swift index 7af57c99..ed5f183e 100644 --- a/Projects/Features/SignUp/Demo/Src/AppDelegate.swift +++ b/Projects/Features/SignUp/Demo/Src/AppDelegate.swift @@ -12,7 +12,9 @@ import UIKit class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - + + registerDependencies() + return true } diff --git a/Projects/Features/SignUp/Demo/Src/Coordinator/AppCoordinator.swift b/Projects/Features/SignUp/Demo/Src/Coordinator/AppCoordinator.swift index b8bb8836..96b0e56b 100644 --- a/Projects/Features/SignUp/Demo/Src/Coordinator/AppCoordinator.swift +++ b/Projects/Features/SignUp/Demo/Src/Coordinator/AppCoordinator.swift @@ -8,6 +8,7 @@ import UIKit import SignUpInterface import Core +import DSKit protocol AppCoordinating { func signUpFlow() @@ -38,11 +39,21 @@ final class AppCoordinator: LaunchCoordinator, AppCoordinating { signUpCoordinator.start() } + + class MainViewController: TFBaseViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let label = UILabel() + label.center = self.view.center + label.text = "Main" + self.view.addSubview(label) + } + } } extension AppCoordinator: SignUpCoordinatorDelegate { func detachSignUp(_ coordinator: Core.Coordinator) { - self.viewControllable.setViewControllers([]) detachChild(coordinator) } } diff --git a/Projects/Features/SignUp/Interface/Src/Coordinator/BottomSheet/BottomSheet.swift b/Projects/Features/SignUp/Interface/Src/Coordinator/BottomSheet/BottomSheet.swift new file mode 100644 index 00000000..d8ffdaba --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Coordinator/BottomSheet/BottomSheet.swift @@ -0,0 +1,33 @@ +// +// File.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/18. +// + +import Foundation + +public enum BottomSheetValueType { + case date(date: Date) + case text(text: String) +} + +public enum BottomSheetViewAction { + case onDismiss +} + +public protocol BottomSheetActionDelegate: AnyObject { + func sheetInvoke(_ action: BottomSheetViewAction) +} + +public protocol BottomSheetListener: AnyObject { + func sendData(item: BottomSheetValueType) +} + +public protocol BottomSheetCoordinator { + +} + +public protocol PickerBottomSheetCoordinator: BottomSheetCoordinator { + func pickerBottomSheetFlow(_ item: BottomSheetValueType, listener: BottomSheetListener) +} diff --git a/Projects/Features/SignUp/Interface/Src/Coordinator/SignUpCoordinating.swift b/Projects/Features/SignUp/Interface/Src/Coordinator/SignUpCoordinating.swift index a60a1283..53788b44 100644 --- a/Projects/Features/SignUp/Interface/Src/Coordinator/SignUpCoordinating.swift +++ b/Projects/Features/SignUp/Interface/Src/Coordinator/SignUpCoordinating.swift @@ -8,6 +8,7 @@ import Foundation import Core +import AuthInterface public protocol SignUpCoordinatorDelegate: AnyObject { func detachSignUp(_ coordinator: Coordinator) @@ -16,10 +17,11 @@ public protocol SignUpCoordinatorDelegate: AnyObject { public protocol SignUpCoordinating: Coordinator { var delegate: SignUpCoordinatorDelegate? { get set } - func rootFlow() func nicknameFlow() func emailFlow() func finishFlow() - func phoneNumberFlow() func policyFlow() + func genderPickerFlow() + func preferGenderPickerFlow() + func photoFlow() } diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Request/LocationReq.swift b/Projects/Features/SignUp/Interface/Src/DTO/Request/LocationReq.swift new file mode 100644 index 00000000..de50f1d3 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Request/LocationReq.swift @@ -0,0 +1,26 @@ +// +// LocaleReq.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +// MARK: - LocationRequest +public struct LocationReq: Codable { + public let address: String + public let regionCode: Int + public let lat, lon: Double + + public init( + address: String, + regionCode: Int, + lat: Double, lon: Double + ) { + self.address = address + self.regionCode = regionCode + self.lat = lat + self.lon = lon + } +} diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Request/SignUpReq.swift b/Projects/Features/SignUp/Interface/Src/DTO/Request/SignUpReq.swift new file mode 100644 index 00000000..b6d0ca03 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Request/SignUpReq.swift @@ -0,0 +1,99 @@ +// +// SignUpReq.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation +import AuthInterface + +public struct SignUpReq: Encodable { + public let phoneNumber, username, email, birthDay: String + public let gender, preferGender: String + public let introduction, deviceKey: String + public let agreement: [String: Bool] + public let locationRequest: LocationReq + public var photoList: [String] + public let interestList, idealTypeList: [Int] + public let snsType: String + public let snsUniqueID: String + public let tall: Int + public let smoking: String + public let drinking: String + public let religion: String + public let contacts: [ContactType] + + enum CodingKeys: String, CodingKey { + case phoneNumber, username, email, birthDay, gender, preferGender, introduction, deviceKey, agreement, locationRequest, photoList, interestList, idealTypeList, snsType + case snsUniqueID = "snsUniqueId" + case tall, smoking, drinking, religion + case contacts + } + + public init(phoneNumber: String, username: String, email: String, birthDay: String, gender: String, preferGender: String, introduction: String, deviceKey: String, agreement: [String: Bool], locationRequest: LocationReq, photoList: [String], interestList: [Int], idealTypeList: [Int], snsType: String, snsUniqueID: String, tall: Int, smoking: String, drinking: String, religion: String, contacts: [ContactType]) { + self.phoneNumber = phoneNumber + self.username = username + self.email = email + self.birthDay = birthDay + self.gender = gender + self.preferGender = preferGender + self.introduction = introduction + self.deviceKey = deviceKey + self.agreement = agreement + self.locationRequest = locationRequest + self.photoList = photoList + self.interestList = interestList + self.idealTypeList = idealTypeList + self.snsType = snsType + self.snsUniqueID = snsUniqueID + self.tall = tall + self.smoking = smoking + self.drinking = drinking + self.religion = religion + self.contacts = contacts + } +} + +extension UserInfo { + public func toRequest(contacts: [ContactType]) -> SignUpReq? { + guard + let username = self.name, + let email = self.email, + let gender = self.gender?.rawValue, + let preferGender = self.preferGender?.rawValue, + let birthday = self.birthday, + let introduction = self.introduction, + let agreement = self.userAgreements, + let location = self.address, + let tall = self.tall, + let drinking = self.drinking?.rawValue, + let smoking = self.smoking?.rawValue, + let religion = self.religion?.rawValue + + else { return nil } + + return SignUpReq( + phoneNumber: self.phoneNumber, + username: username, + email: email, + birthDay: birthday, + gender: gender, + preferGender: preferGender, + introduction: introduction, + deviceKey: "device-key", + agreement: agreement, + locationRequest: location, + photoList: photos, + interestList: interestsList, + idealTypeList: idealTypeList, + snsType: SNSType.normal.rawValue, + snsUniqueID: "", + tall: tall, + smoking: smoking, + drinking: drinking, + religion: religion, + contacts: contacts + ) + } +} diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Request/UserFriendContactReq.swift b/Projects/Features/SignUp/Interface/Src/DTO/Request/UserFriendContactReq.swift new file mode 100644 index 00000000..fe130081 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Request/UserFriendContactReq.swift @@ -0,0 +1,16 @@ +// +// BlockRes.swift +// SignUpInterface +// +// Created by kangho lee on 5/2/24. +// + +import Foundation + +public struct UserFriendContactReq: Codable { + public let contacts: [ContactType] + + public init(contacts: [ContactType]) { + self.contacts = contacts + } +} diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Response/AddressRes.swift b/Projects/Features/SignUp/Interface/Src/DTO/Response/AddressRes.swift new file mode 100644 index 00000000..70119e1f --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Response/AddressRes.swift @@ -0,0 +1,42 @@ +// +// DocumentFromAddressRes.swift +// SignUp +// +// Created by Kanghos on 5/5/24. +// + +import Foundation + +// MARK: - AddressResponse +struct AddressRes: Codable { + let documents: [Document] + + // MARK: - Document + struct Document: Codable { + let address: Address + let addressName: String + let roadAddress: Address + let lon: String + let lat: String + + enum CodingKeys: String, CodingKey { + case address + case addressName = "address_name" + case roadAddress = "road_address" + case lon = "x" + case lat = "y" + } + } + + // MARK: - Address + struct Address: Codable { + let addressName, region1DepthName, region2DepthName, region3DepthName: String + + enum CodingKeys: String, CodingKey { + case addressName = "address_name" + case region1DepthName = "region_1depth_name" + case region2DepthName = "region_2depth_name" + case region3DepthName = "region_3depth_name" + } + } +} diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Response/EmojiResponse.swift b/Projects/Features/SignUp/Interface/Src/DTO/Response/EmojiResponse.swift new file mode 100644 index 00000000..ab34dc6c --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Response/EmojiResponse.swift @@ -0,0 +1,8 @@ +// +// EmojiResponse.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation diff --git a/Projects/Features/SignUp/Interface/Src/Model/PhoneValidationResponse.swift b/Projects/Features/SignUp/Interface/Src/DTO/Response/PhoneValidationRes.swift similarity index 86% rename from Projects/Features/SignUp/Interface/Src/Model/PhoneValidationResponse.swift rename to Projects/Features/SignUp/Interface/Src/DTO/Response/PhoneValidationRes.swift index 90f26201..10a78ab5 100644 --- a/Projects/Features/SignUp/Interface/Src/Model/PhoneValidationResponse.swift +++ b/Projects/Features/SignUp/Interface/Src/DTO/Response/PhoneValidationRes.swift @@ -7,7 +7,7 @@ import Foundation -public struct PhoneValidationResponse { +public struct PhoneValidationResponse: Decodable { public let phoneNumber: String public let authNumber: Int diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Response/UserFriendContactRes.swift b/Projects/Features/SignUp/Interface/Src/DTO/Response/UserFriendContactRes.swift new file mode 100644 index 00000000..6d04228d --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Response/UserFriendContactRes.swift @@ -0,0 +1,12 @@ +// +// UserFriendContactResponse.swift +// SignUpInterface +// +// Created by kangho lee on 5/2/24. +// + +import Foundation + +public struct UserFriendContactRes: Codable { + public let count: Int +} diff --git a/Projects/Features/SignUp/Interface/Src/DTO/Response/UserNicknameValidRes.swift b/Projects/Features/SignUp/Interface/Src/DTO/Response/UserNicknameValidRes.swift new file mode 100644 index 00000000..bebcbcad --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/DTO/Response/UserNicknameValidRes.swift @@ -0,0 +1,16 @@ +// +// UserNicknameValidResponse.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +public struct UserNicknameValidRes: Codable { + public let isDuplicate: Bool + + public init(isDuplicate: Bool) { + self.isDuplicate = isDuplicate + } +} diff --git a/Projects/Features/SignUp/Interface/Src/Model/Agrement.swift b/Projects/Features/SignUp/Interface/Src/Model/Agrement.swift new file mode 100644 index 00000000..8d8cf225 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/Agrement.swift @@ -0,0 +1,18 @@ +// +// Agrement.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +// MARK: - AgreementElement +public struct AgreementElement: Codable { + public let name, subject: String + public let isRequired: Bool + public let description: String? + public let detailLink: String? +} + +public typealias Agreement = [AgreementElement] diff --git a/Projects/Features/SignUp/Interface/Src/Model/ContactType.swift b/Projects/Features/SignUp/Interface/Src/Model/ContactType.swift new file mode 100644 index 00000000..3cb3ee3c --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/ContactType.swift @@ -0,0 +1,18 @@ +// +// ContactType.swift +// SignUpInterface +// +// Created by Kanghos on 5/12/24. +// + +import Foundation + +public struct ContactType: Codable { + public let name: String + public let phoneNumber: String + + public init(name: String, phoneNumber: String) { + self.name = name + self.phoneNumber = phoneNumber + } +} diff --git a/Projects/Features/SignUp/Interface/Src/Model/EmojiType.swift b/Projects/Features/SignUp/Interface/Src/Model/EmojiType.swift new file mode 100644 index 00000000..46067bfa --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/EmojiType.swift @@ -0,0 +1,25 @@ +// +// EmojiType.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +public struct EmojiType: Codable { + public let index: Int + public let name: String + public let emojiCode: String + + enum CodingKeys: String, CodingKey { + case index = "idx" + case name, emojiCode + } + + public init(index: Int, name: String, emojiCode: String) { + self.index = index + self.name = name + self.emojiCode = emojiCode + } +} diff --git a/Projects/Features/SignUp/Interface/Src/Model/Frequency.swift b/Projects/Features/SignUp/Interface/Src/Model/Frequency.swift new file mode 100644 index 00000000..e7e01bbb --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/Frequency.swift @@ -0,0 +1,14 @@ +// +// Frequency.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +public enum Frequency: String, Codable { + case sometimes = "SOMETIMES" + case frequently = "FREQUENTLY" + case none = "NONE" +} diff --git a/Projects/Features/SignUp/Interface/Src/Model/Gender.swift b/Projects/Features/SignUp/Interface/Src/Model/Gender.swift new file mode 100644 index 00000000..2bac960f --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/Gender.swift @@ -0,0 +1,27 @@ +// +// Gender.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +public enum Gender: String, Codable { + case male = "MALE" + case female = "FEMALE" + case both = "BOTH" + + public init?(number: Int) { + switch number { + case 1: + self = .male + case 0: + self = .female + case 2: + self = .both + default: + return nil + } + } +} diff --git a/Projects/Features/SignUp/Interface/Src/Model/Religion.swift b/Projects/Features/SignUp/Interface/Src/Model/Religion.swift new file mode 100644 index 00000000..8f21002b --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/Religion.swift @@ -0,0 +1,17 @@ +// +// Religion.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +public enum Religion: String, Codable { + case christian = "CHRISTIAN" + case catholic = "CATHOLICISM" + case buddhism = "BUDDHISM" + case wonBuddhism = "WON_BUDDHISM" + case none = "NONE" + case other = "OTHER" +} diff --git a/Projects/Features/SignUp/Interface/Src/Model/UserInfo.swift b/Projects/Features/SignUp/Interface/Src/Model/UserInfo.swift new file mode 100644 index 00000000..0abd9092 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/Model/UserInfo.swift @@ -0,0 +1,49 @@ +// +// UserInfo.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation +import Domain + +public struct UserInfo: Codable { + public var phoneNumber: String + public var name: String? + public var userUUID: String? + public var birthday: String? + public var introduction: String? + public var address: LocationReq? + public var email: String? + public var gender: Gender? + public var preferGender: Gender? + public var tall: Int? + public var smoking: Frequency? + public var drinking: Frequency? + public var religion: Religion? + public var idealTypeList: [Int] + public var interestsList: [Int] + public var photos: [String] + public var userAgreements: [String: Bool]? + + public init(phoneNumber: String, name: String? = nil, userUUID: String? = nil, birthday: String? = nil, introduction: String? = nil, address: LocationReq? = nil, email: String? = nil, gender: Gender? = nil, preferGender: Gender? = nil, tall: Int? = nil, smoking: Frequency? = nil, drinking: Frequency? = nil, religion: Religion? = nil, idealTypeList: [Int] = [], interestsList: [Int] = [], photos: [String] = [], userAgreements: [String: Bool]? = nil) { + self.phoneNumber = phoneNumber + self.name = name + self.userUUID = userUUID + self.birthday = birthday + self.introduction = introduction + self.address = address + self.email = email + self.gender = gender + self.preferGender = preferGender + self.tall = tall + self.smoking = smoking + self.drinking = drinking + self.religion = religion + self.idealTypeList = idealTypeList + self.interestsList = interestsList + self.photos = photos + self.userAgreements = userAgreements + } +} diff --git a/Projects/Features/SignUp/Interface/Src/RepositoryInterface/SignUpRepositoryInterface.swift b/Projects/Features/SignUp/Interface/Src/RepositoryInterface/SignUpRepositoryInterface.swift new file mode 100644 index 00000000..e3635cb8 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/RepositoryInterface/SignUpRepositoryInterface.swift @@ -0,0 +1,20 @@ +// +// SignUpRepositoryInterface.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +import RxSwift +import AuthInterface + +public protocol SignUpRepositoryInterface { + func checkNickname(nickname: String) -> Single + func idealTypes() -> Single<[EmojiType]> + func interests() -> Single<[EmojiType]> + func signUp(_ signUpRequest: SignUpReq) -> Single + func uploadImage(data: [Data]) -> Single<[String]> + func fetchAgreements() -> Single +} diff --git a/Projects/Features/SignUp/Interface/Src/RepositoryInterface/UserInfoRepositoryInterface.swift b/Projects/Features/SignUp/Interface/Src/RepositoryInterface/UserInfoRepositoryInterface.swift new file mode 100644 index 00000000..fd5a44a1 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/RepositoryInterface/UserInfoRepositoryInterface.swift @@ -0,0 +1,19 @@ +// +// UserInfoRepository.swift +// SignUpInterface +// +// Created by Kanghos on 5/29/24. +// + +import Foundation +import RxSwift + +public protocol UserInfoRepositoryInterface { + func savePhoneNumber(_ phoneNumber: String) + func fetchPhoneNumber() -> Single + func fetchUserInfo() -> Single + func updateUserInfo(userInfo: UserInfo) + func deleteUserInfo() + func fetchUserPhotos(key: String, fileNames: [String]) -> Single<[Data]> + func saveUserPhotos(key: String, datas: [Data]) -> Single<[String]> +} diff --git a/Projects/Features/SignUp/Interface/Src/UseCase/ContactServiceType.swift b/Projects/Features/SignUp/Interface/Src/UseCase/ContactServiceType.swift new file mode 100644 index 00000000..a00a6def --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/UseCase/ContactServiceType.swift @@ -0,0 +1,14 @@ +// +// ContactServiceType.swift +// SignUpInterface +// +// Created by Kanghos on 5/14/24. +// + +import Foundation + +import RxSwift + +public protocol ContactServiceType { + func fetchContact() -> Single<[ContactType]> +} diff --git a/Projects/Features/SignUp/Interface/Src/UseCase/KakaoAPIServiceType.swift b/Projects/Features/SignUp/Interface/Src/UseCase/KakaoAPIServiceType.swift new file mode 100644 index 00000000..480c9ab7 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/UseCase/KakaoAPIServiceType.swift @@ -0,0 +1,15 @@ +// +// KakaoAPIServiceType.swift +// SignUpInterface +// +// Created by Kanghos on 5/14/24. +// + +import Foundation + +import RxSwift + +public protocol KakaoAPIServiceType { + func fetchLocationByCoordinate2d(longitude: Double, latitude: Double) -> Single + func fetchLocationByAddress(address: String) -> Single +} diff --git a/Projects/Features/SignUp/Interface/Src/UseCase/LocationServiceType.swift b/Projects/Features/SignUp/Interface/Src/UseCase/LocationServiceType.swift new file mode 100644 index 00000000..1526a6d2 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/UseCase/LocationServiceType.swift @@ -0,0 +1,21 @@ +// +// LocationService.swift +// SignUpInterface +// +// Created by kangho lee on 4/29/24. +// + +import Foundation +import RxSwift + +public protocol LocationServiceType { + var publisher: PublishSubject { get } + func handleAuthorization(granted: @escaping (Bool) -> Void) + func requestLocation() + func requestAuthorization() +} + +public enum LocationError: Error { + case denied + case invalidLocation +} diff --git a/Projects/Features/SignUp/Interface/Src/UseCase/SignUpError.swift b/Projects/Features/SignUp/Interface/Src/UseCase/SignUpError.swift new file mode 100644 index 00000000..435a2025 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/UseCase/SignUpError.swift @@ -0,0 +1,14 @@ +// +// SignUpError.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +public enum SignUpError: Error { + case alreadySignUp + case duplicateNickname + case invalidRequest +} diff --git a/Projects/Features/SignUp/Interface/Src/UseCase/SignUpUseCaseInterface.swift b/Projects/Features/SignUp/Interface/Src/UseCase/SignUpUseCaseInterface.swift new file mode 100644 index 00000000..19980144 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/UseCase/SignUpUseCaseInterface.swift @@ -0,0 +1,23 @@ +// +// SignUpUseCase.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation + +import RxSwift +import Domain + +public protocol SignUpUseCaseInterface { + func checkNickname(nickname: String) -> Single + func idealTypes() -> Single<[Domain.EmojiType]> + func interests() -> Single<[Domain.EmojiType]> + func block() -> Single<[ContactType]> + func fetchLocation() -> Single + func fetchLocation(_ address: String) -> Single + func signUp(request: SignUpReq) -> Single + func uploadImage(data: [Data]) -> Single<[String]> + func fetchAgreements() -> Single +} diff --git a/Projects/Features/SignUp/Interface/Src/UseCase/UserInfoUseCaseInterface.swift b/Projects/Features/SignUp/Interface/Src/UseCase/UserInfoUseCaseInterface.swift new file mode 100644 index 00000000..41323721 --- /dev/null +++ b/Projects/Features/SignUp/Interface/Src/UseCase/UserInfoUseCaseInterface.swift @@ -0,0 +1,19 @@ +// +// UserInfoUseCaseInterface.swift +// SignUpInterface +// +// Created by Kanghos on 5/29/24. +// + +import Foundation +import RxSwift + +public protocol UserInfoUseCaseInterface { + func savePhoneNumber(_ phoneNumber: String) + func fetchPhoneNumber() -> Single + func fetchUserInfo() -> Single + func updateUserInfo(userInfo: UserInfo) + func deleteUserInfo() + func fetchUserPhotos(key: String, fileNames: [String]) -> Single<[Data]> + func saveUserPhotos(key: String, datas: [Data]) -> Single<[String]> +} diff --git a/Projects/Features/SignUp/Project.swift b/Projects/Features/SignUp/Project.swift index 22ee432c..5de1545d 100644 --- a/Projects/Features/SignUp/Project.swift +++ b/Projects/Features/SignUp/Project.swift @@ -16,6 +16,7 @@ let project = Project( interface: .SignUp, dependencies: [ .core, + .feature(interface: .Auth) ] ), .feature( @@ -29,7 +30,8 @@ let project = Project( .feature( demo: .SignUp, dependencies: [ - .feature(implementation: .SignUp) + .feature(implementation: .SignUp), + .data ] ) ] diff --git a/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+BottomSheet.swift b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+BottomSheet.swift new file mode 100644 index 00000000..27e14678 --- /dev/null +++ b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+BottomSheet.swift @@ -0,0 +1,30 @@ +// +// SignUpCoordinator+BottomSheet.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation + +import Core +import SignUpInterface + +extension SignUpCoordinator: PickerBottomSheetCoordinator { + public func pickerBottomSheetFlow(_ item: BottomSheetValueType, listener: BottomSheetListener) { + let vm = PickerBottomSheetViewModel(initialValue: item) + vm.listener = listener + vm.delegate = self + let vc = PickerBottomSheet(viewModel: vm) + + self.viewControllable.presentBottomSheet(vc, animated: true) + } + + public func singlePickerBottomSheetFlow(_ item: BottomSheetValueType, listener: BottomSheetListener) { + let vm = SinglePickerBottomSheetViewModel(initialValue: item) + vm.listener = listener + vm.delegate = self + let vc = SinglePickerBottomSheet(viewModel: vm) + self.viewControllable.presentBottomSheet(vc, animated: true) + } +} diff --git a/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+Contacts.swift b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+Contacts.swift new file mode 100644 index 00000000..c2639d02 --- /dev/null +++ b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+Contacts.swift @@ -0,0 +1,84 @@ +//// +//// SignUpCoordinator+Contacts.swift +//// SignUp +//// +//// Created by kangho lee on 5/6/24. +//// +// +//import Foundation +//import ContactsUI +//import SignUpInterface +// +//import Core +// +//public protocol UserContactPickerDelegate: CNContactPickerDelegate { +// var listener: UserContactListener? { get } +//} +// +//public protocol UserContactListener: AnyObject { +// func picker(didFinishPicking contacts: [UserFriendContactReq.Contact]) +//} +// +//extension SignUpCoordinator { +// public func presentContactsUI(delegate: UserContactPickerDelegate) { +// let picker = CNContactPickerViewController() +// picker.delegate = delegate +// +// self.viewControllable.present(picker, animated: true) +// } +//} +// +//extension CNContactPickerViewController: ViewControllable { +// public var uiController: UIViewController { return self } +//} +// +//public class UserContactPickerDelegator: NSObject, UserContactPickerDelegate { +// public weak var listener: UserContactListener? +// +// init(listener: UserContactListener) { +// self.listener = listener +// } +// +// deinit { +// print("deinit: ContactsPickerDelegate!") +// } +// +// public func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) { +// var mutableContacts: [UserFriendContactReq.Contact] = [] +// +// let store = CNContactStore() +// let keysToFetch = [ +// CNContactGivenNameKey, +// CNContactFamilyNameKey, +// CNContactPhoneNumbersKey, +// CNContactOrganizationNameKey, +// ] as [CNKeyDescriptor] +// +// contacts.forEach { contact in +// do { +// let predicate = CNContact.predicateForContacts(withIdentifiers: [contact.identifier]) +// guard let contact = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch).first +// else { return } +// let name: String +// +// if contact.familyName.count == 0 && contact.givenName.count == 0 { +// name = "\(contact.organizationName)" +// } else { +// name = "\(contact.familyName)\(contact.givenName)" +// } +// +// if let phone = contact.phoneNumbers.first { +// let phoneNumber = "\(phone.value.stringValue)" +// mutableContacts.append(.init(name: name, phoneNumber: phoneNumber)) +// } +// +// } catch { +// print("Failed to fetch, error: \(error)") +// } +// } +// +// listener?.picker(didFinishPicking: mutableContacts) +// picker.dismiss() +// } +// +//} diff --git a/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+PHPicker.swift b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+PHPicker.swift new file mode 100644 index 00000000..b1e481cb --- /dev/null +++ b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator+PHPicker.swift @@ -0,0 +1,51 @@ +// +// SignUpCoordinator+PHPicker.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation +import PhotosUI + +import Core + +extension SignUpCoordinator { + public func photoPickerFlow(delegate: PhotoPickerDelegate) { + + // coordinator로 빼기 + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + + configuration.filter = PHPickerFilter.images + + configuration.preferredAssetRepresentationMode = .current + configuration.selection = .ordered + + configuration.selectionLimit = 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = delegate + self.viewControllable.present(picker, animated: true) + } +} + +extension PHPickerViewController: ViewControllable { + public var uiController: UIViewController { return self } +} + +public protocol PhotoPickerDelegate: PHPickerViewControllerDelegate { + var listener: PhotoPickerListener? { get } +} + +public class PhotoPickerDelegator: PhotoPickerDelegate { + weak public var listener: PhotoPickerListener? + + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss() + listener?.picker(didFinishPicking: results) + } + + deinit { + print("deinit: PhotoPickerDelegate!") + } +} diff --git a/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator.swift b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator.swift index 8d348383..da5218c9 100644 --- a/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator.swift +++ b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinator.swift @@ -9,42 +9,48 @@ import Foundation import Core import SignUpInterface +import AuthInterface +import DSKit + +protocol SignUpCoordinatingActionDelegate: AnyObject { + func invoke(_ action: SignUpCoordinatingAction) +} public final class SignUpCoordinator: BaseCoordinator, SignUpCoordinating { - public weak var delegate: SignUpCoordinatorDelegate? + @Injected private var useCase: SignUpUseCaseInterface + @Injected private var userInfoUseCase: UserInfoUseCaseInterface + + public weak var delegate: SignUpCoordinatorDelegate? + // TODO: UserDefaultStorage이용해서 어느 화면 띄워줄건지 결정 public override func start() { - replaceWindowRootViewController(rootViewController: self.viewControllable) - - rootFlow() + replaceWindowRootViewController(rootViewController: viewControllable) + emailFlow() } - - public func rootFlow() { - let viewModel = SignUpRootViewModel() - viewModel.delegate = self - - let viewController = SignUpRootViewController() - viewController.viewModel = viewModel - self.viewControllable.setViewControllers([viewController]) + public func locationFlow() { + let viewModel = LocationInputViewModel(useCase: useCase, userInfoUseCase: self.userInfoUseCase) + viewModel.delegate = self + let viewController = LocationInputViewController(viewModel: viewModel) + self.viewControllable.pushViewController(viewController, animated: true) } - + public func finishFlow() { self.delegate?.detachSignUp(self) } - + public func emailFlow() { - let viewModel = EmailInputViewModel() + let viewModel = EmailInputViewModel(userInfoUseCase: self.userInfoUseCase) viewModel.delegate = self let viewController = EmailInputViewController(viewModel: viewModel) self.viewControllable.pushViewController(viewController, animated: true) } - + public func nicknameFlow() { - let viewModel = NicknameInputViewModel() + let viewModel = NicknameInputViewModel(useCase: useCase, userInfoUseCase: self.userInfoUseCase) viewModel.delegate = self let viewController = NicknameInputViewController(viewModel: viewModel) @@ -52,7 +58,7 @@ public final class SignUpCoordinator: BaseCoordinator, SignUpCoordinating { } public func policyFlow() { - let viewModel = PolicyAgreementViewModel() + let viewModel = PolicyAgreementViewModel(useCase: self.useCase, userInfoUseCase: self.userInfoUseCase) viewModel.delegate = self let viewController = PolicyAgreementViewController(viewModel: viewModel) @@ -60,42 +66,165 @@ public final class SignUpCoordinator: BaseCoordinator, SignUpCoordinating { self.viewControllable.pushViewController(viewController, animated: true) } - public func phoneNumberFlow() { - let viewModel = PhoneCertificationViewModel() - viewModel.delegate = self + public func genderPickerFlow() { + let vm = GenderPickerViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + let vc = GenderPickerViewController(viewModel: vm) + + self.viewControllable.pushViewController(vc, animated: true) + } - let viewController = PhoneCertificationViewController(viewModel: viewModel) + public func preferGenderPickerFlow() { + let vm = PreferGenderPickerViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + let vc = PreferGenderPickerViewController(viewModel: vm) - self.viewControllable.pushViewController(viewController, animated: true) + self.viewControllable.pushViewController(vc, animated: true) } -} -extension SignUpCoordinator: SignUpRootDelegate { - func toPhoneButtonTap() { - phoneNumberFlow() + public func photoFlow() { + let vm = PhotoInputViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + let vc = PhotoInputViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) } -} -extension SignUpCoordinator: PhoneCertificationDelegate { - func finishAuth() { - emailFlow() + public func heightPickerFlow() { + let vm = HeightPickerViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + let vc = HeightPickerViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) } -} -extension SignUpCoordinator: EmailInputDelegate { - func emailNextButtonTap() { - policyFlow() + public func InterestTagPickerFlow() { + let vm = TagPickerViewModel(useCase: useCase, userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + let vc = InterestPickerViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + public func IdealTypeTagPickerFlow() { + let vm = IdealTypeTagPickerViewModel(useCase: useCase, userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + + let vc = IdealTypePickerViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + public func IntroductFlow() { + let vm = IntroduceInputViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + + let vc = IntroduceInputViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + public func alcoholTobaccoFlow() { + let vm = AlcoholTobaccoPickerViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + + let vc = AlcoholTobaccoPickerViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + public func religionFlow() { + let vm = ReligionPickerViewModel(userInfoUseCase: self.userInfoUseCase) + vm.delegate = self + let vc = ReligionPickerViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + func webViewFlow(listener: WebViewDelegate) { + let vc = PostCodeWebViewController() + vc.delegate = listener + vc.modalPresentationStyle = .overFullScreen + self.viewControllable.present(vc, animated: true) + } + + func blockUserFriendContactFlow() { + let vm = UserContactViewModel(useCase: self.useCase) + vm.delegate = self + let vc = UserContactViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + func signUpCompleteFlow(_ contacts: [ContactType]) { + let vm = SignUpCompleteViewModel(useCase: self.useCase, userInfoUseCase: userInfoUseCase, contacts: contacts) + vm.delegate = self + let vc = SignUpCompleteViewController(viewModel: vm) + self.viewControllable.pushViewController(vc, animated: true) + } + + private func agreementWebViewFlow(url: URL) { + let vc = TFWebViewController(url: url) + let nav = NavigationViewControllable(rootViewControllable: vc) + self.viewControllable.present(nav, animated: true) } } -extension SignUpCoordinator: PolicyAgreementDelegate { - func policyNextButtonTap() { - nicknameFlow() + +extension SignUpCoordinator: SignUpCoordinatingActionDelegate { + func invoke(_ action: SignUpCoordinatingAction) { + switch action { + case let .loginType(snsType): + print(snsType) + case .nextAtPhoneNumber: + emailFlow() + case .nextAtEmail: + policyFlow() + case .nextAtPolicy: + nicknameFlow() + case .nextAtNickname: + genderPickerFlow() + case let .birthdayTap(birthDay, listener): + pickerBottomSheetFlow(.date(date: birthDay), listener: listener) + case .nextAtGender: + preferGenderPickerFlow() + case .nextAtPreferGender: + photoFlow() + + case let .photoCellTap(_, listener): + photoPickerFlow(delegate: listener) + case .nextAtPhoto: + heightPickerFlow() + + case let .heightLabelTap(height, listener): + singlePickerBottomSheetFlow(.text(text: String(height)), listener: listener) + + case .nextAtHeight: + alcoholTobaccoFlow() + + case .nextAtAlcoholTobacco: + religionFlow() + + case .nextAtReligion: + InterestTagPickerFlow() + case .nextAtInterest: + IdealTypeTagPickerFlow() + + case .nextAtIdealType: + IntroductFlow() + case .nextAtIntroduce: + locationFlow() + case let .webViewTap(listener): + webViewFlow(listener: listener) + case .nextAtLocation: + blockUserFriendContactFlow() + case let .nextAtHideFriends(contacts): + signUpCompleteFlow(contacts) + case .nextAtSignUpComplete: + finishFlow() + case let .agreementWebView(url): + agreementWebViewFlow(url: url) + default: break + } } } -extension SignUpCoordinator: NicknameInputDelegate { - func nicknameNextButtonTap() { - +extension SignUpCoordinator: BottomSheetActionDelegate { + public func sheetInvoke(_ action: BottomSheetViewAction) { + if case .onDismiss = action { + self.viewControllable.uiController.dismiss(animated: true) + } } } diff --git a/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinatorAction.swift b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinatorAction.swift new file mode 100644 index 00000000..6df00871 --- /dev/null +++ b/Projects/Features/SignUp/Src/Coordinator/SignUpCoordinatorAction.swift @@ -0,0 +1,43 @@ +// +// SignUpCoordinatorAction.swift +// SignUp +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation +import SignUpInterface +import AuthInterface + +public enum SignUpCoordinatingAction { + + case loginType(SNSType) + + case nextAtPhoneNumber(phoneNumber: String) + case nextAtAuthCode + case nextAtEmail + + case nextAtPolicy + case nextAtNickname + case nextAtGender + case nextAtPreferGender + case nextAtPhoto + case nextAtHeight + case nextAtAlcoholTobacco + case nextAtReligion + case nextAtInterest + case nextAtIdealType + case nextAtIntroduce + case nextAtLocation + + case nextAtHideFriends([ContactType]) + case nextAtSignUpComplete + + + case webViewTap(listner: WebViewDelegate) + case birthdayTap(Date, listener: BottomSheetListener) + case heightLabelTap(Int, listener: BottomSheetListener) + case photoCellTap(index: Int, listener: PhotoPickerDelegate) + + case agreementWebView(_ url: URL) +} diff --git a/Projects/Features/SignUp/Src/Coordinator/SignUpStore.swift b/Projects/Features/SignUp/Src/Coordinator/SignUpStore.swift new file mode 100644 index 00000000..f15bede0 --- /dev/null +++ b/Projects/Features/SignUp/Src/Coordinator/SignUpStore.swift @@ -0,0 +1,106 @@ +// +// SignUpStore.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import Foundation +import SignUpInterface +import Core + +protocol Signing { + +} + +public struct SignUpStore { + + enum Key: String { + case snsType + case phoneNumber + case email + case policy + case nickname + case gender + case birthday + case preferGender + case photo + + // ------- API 미구현 --------- + case height + case smoke + case drink + case religion + // ------- -------- --------- + + case interest + case ideal + case introduce + case address + case avoidFriends + } + @Storage(key: Key.snsType.rawValue, defaultValue: "") + public static var snstype + + @Storage(key: Key.phoneNumber.rawValue, defaultValue: "") + public static var phoneNumber + + + @Storage(key: Key.email.rawValue, defaultValue: "") + public static var email + + @Storage(key: Key.email.rawValue, defaultValue: -1) + public static var policy + + @Storage(key: Key.nickname.rawValue, defaultValue: "") + public static var nickname + + @Storage(key: Key.gender.rawValue, defaultValue: "") + public static var gender + + @Storage(key: Key.preferGender.rawValue, defaultValue: "") + public static var preferGender + + @Storage(key: Key.birthday.rawValue, defaultValue: "") + public static var birthday + + @Storage(key: Key.photo.rawValue, defaultValue: "") + public static var photo + + @Storage(key: Key.height.rawValue, defaultValue: 145) + public static var height + + @Storage(key: Key.smoke.rawValue, defaultValue: "") + public static var smoke + + @Storage(key: Key.drink.rawValue, defaultValue: "") + public static var drink + + @Storage(key: Key.religion.rawValue, defaultValue: "") + public static var religion + + @Storage<[Int]>(key: Key.interest.rawValue, defaultValue: []) + public static var interest + + @Storage<[Int]>(key: Key.ideal.rawValue, defaultValue: []) + public static var ideal + + @Storage(key: Key.introduce.rawValue, defaultValue: "") + public static var introduce + + @CodableStorage<[ContactType]>(key: Key.avoidFriends.rawValue, defaultValue: []) + public static var avoidFriends + + @CodableStorage(key: Key.address.rawValue, defaultValue: nil) + public static var location +} + +extension SignUpStore { + static func savePhotoData(_ data: Data) { + let base64String = data.base64EncodedString() + SignUpStore.photo = base64String + } +// func checkStartFlow() -> SignUpCoordinatingAction { +// if phone +// } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoInputViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoInputViewController.swift new file mode 100644 index 00000000..32842504 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoInputViewController.swift @@ -0,0 +1,65 @@ +// +// AlcoholTobaccoInputViewControlle.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import DSKit + +import RxSwift +import RxCocoa + +final class AlcoholTobaccoPickerViewController: TFBaseViewController { + private let mainView = AlcoholTobaccoPickerView() + private let viewModel: AlcoholTobaccoPickerViewModel + + init(viewModel: AlcoholTobaccoPickerViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + let tobaccoTap = self.mainView.tobaccoPickerView + .rx.selectedOption + .asDriver() + .compactMap { $0 } + .map { FrequencyMapper.toFrequency($0) } + + let alcoholTap = self.mainView.alcoholPickerView + .rx.selectedOption + .asDriver() + .compactMap { $0 } + .map { FrequencyMapper.toFrequency($0) } + + let input = AlcoholTobaccoPickerViewModel.Input( + tobaccoTap: tobaccoTap, + alcoholTap: alcoholTap, + nextBtnTap: self.mainView.nextBtn.rx.tap.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.isNextBtnEnabled + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + + output.initialFrequecy + .debug("initial") + .drive(mainView.rx.selectedFrequecy) + .disposed(by: disposeBag) + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoInputViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoInputViewModel.swift new file mode 100644 index 00000000..f4c3161d --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoInputViewModel.swift @@ -0,0 +1,89 @@ +// +// AlcoholTobaccoInputViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation + +import Core +import SignUpInterface + +import RxSwift +import RxCocoa + +final class AlcoholTobaccoPickerViewModel: ViewModelType { + weak var delegate: SignUpCoordinatingActionDelegate? + private let userInfoUseCase: UserInfoUseCaseInterface + + struct Input { + var tobaccoTap: Driver + var alcoholTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var isNextBtnEnabled: Driver + let initialFrequecy: Driver + } + + enum FrequencyType { + case smoking(Frequency) + case drinking(Frequency) + } + + private var disposeBag = DisposeBag() + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + .debug("userinfo") + + let smoke = userinfo.compactMap { $0.smoking }.map { FrequencyType.smoking($0) } + let drink = userinfo.compactMap { $0.drinking }.map { FrequencyType.drinking($0) } + + let initialFrequency = Driver.concat([smoke, drink]) + + let selectedTobacco = input.tobaccoTap + let selectedAlcohol = input.alcoholTap + + let nextBtnisEnabled = Driver.combineLatest(selectedAlcohol, selectedTobacco).map { _ in true } + + let updatedUserInfo = Driver.combineLatest(selectedAlcohol, selectedTobacco) { ($0, $1) } + .withLatestFrom(userinfo) { component, userinfo in + let (drink, smoke) = component + var mutable = userinfo + mutable.smoking = smoke + mutable.drinking = drink + return mutable + } + + input.nextBtnTap + .withLatestFrom(nextBtnisEnabled) + .filter { $0 } + .map { _ in } + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(updatedUserInfo) + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtAlcoholTobacco) + }.disposed(by: disposeBag) + + return Output( + isNextBtnEnabled: nextBtnisEnabled, + initialFrequecy: initialFrequency + ) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoPickerView.swift b/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoPickerView.swift new file mode 100644 index 00000000..19408106 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/AlcoholTobaccoInput/AlcoholTobaccoPickerView.swift @@ -0,0 +1,160 @@ +// +// AlcoholTobaccoInputView.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit +import SignUpInterface +import DSKit +import RxSwift + +class AlcoholTobaccoPickerView: TFBaseView { + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "추가정보를 알려주세요" + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH1B + $0.asColor(targetString: "추가정보", color: DSKitAsset.Color.neutral50.color) + } + + lazy var tobaccoPickerView = TFButtonPickerView( + title: "흡연 스타일을 선택해주세요.", targetString: "흡연", + options: [ + "안함", + "가끔", + "자주" + ], + titleType: .sub + ) + + lazy var alcoholPickerView = TFButtonPickerView( + title: "주량을 선택해주세요.", targetString: "주량", + options: [ + "안함", + "가끔", + "자주" + ], + titleType: .sub + ) + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.text = "마이페이지에서 변경가능합니다." + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 1 + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + container.addSubviews( + titleLabel, + tobaccoPickerView, + alcoholPickerView, + infoImageView, descLabel, + nextBtn + ) + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview().inset(30) + } + + tobaccoPickerView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(35) + $0.leading.trailing.equalToSuperview().inset(0) + } + + alcoholPickerView.snp.makeConstraints { + $0.top.equalTo(tobaccoPickerView.snp.bottom).offset(30) + $0.leading.trailing.equalToSuperview().inset(0) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(titleLabel.snp.leading) + $0.width.height.equalTo(16) + $0.top.equalTo(alcoholPickerView.snp.bottom).offset(16) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.top.equalTo(alcoholPickerView.snp.bottom).offset(16) + $0.trailing.equalToSuperview().inset(38) + } + nextBtn.snp.makeConstraints { + $0.top.equalTo(descLabel.snp.bottom).offset(30) + $0.trailing.equalTo(descLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} + +extension Reactive where Base == AlcoholTobaccoPickerView { + var selectedFrequecy: Binder { + Binder(base.self) { view, frequencyType in + switch frequencyType { + case let .drinking(frequency): + view.alcoholPickerView.handleSelectedState(FrequencyMapper.toOption(frequency)) + case let .smoking(frequency): + view.tobaccoPickerView.handleSelectedState(FrequencyMapper.toOption(frequency)) + } + } + } +} + +struct FrequencyMapper { + static func toOption(_ frequency: Frequency) -> TFButtonPickerView.Option { + switch frequency { + case .none: + return .init(key: 0, value: "안함") + case .sometimes: + return .init(key: 1, value: "가끔") + case .frequently: + return .init(key: 2, value: "자주") + } + } + + static func toFrequency(_ option: TFButtonPickerView.Option) -> Frequency { + switch option.key { + case 0: return .none + case 1: return .sometimes + default: return .frequently + } + } + + static var options: [String] { + return ["안함", "가끔", "자주"] + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AlcoholTobaccoPickerViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return AlcoholTobaccoPickerView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewController.swift index c34f3913..369de850 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewController.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewController.swift @@ -21,6 +21,7 @@ class EmailInputViewController: TFBaseViewController { $0.placeholder = "welcome@falling.com" $0.textColor = DSKitAsset.Color.primary500.color $0.font = .thtH2B + $0.autocapitalizationType = .none } private lazy var clearBtn: UIButton = UIButton().then { @@ -34,7 +35,7 @@ class EmailInputViewController: TFBaseViewController { } private lazy var descView = UIView().then { - $0.backgroundColor = .cyan + $0.backgroundColor = .clear } private lazy var descImageView: UIImageView = UIImageView().then { @@ -80,11 +81,7 @@ class EmailInputViewController: TFBaseViewController { super.viewDidAppear(animated) emailTextField.becomeFirstResponder() } - - override func viewDidLoad() { - super.viewDidLoad() - keyboardSetting() - } + override func makeUI() { [ @@ -156,7 +153,7 @@ class EmailInputViewController: TFBaseViewController { nextButton.snp.makeConstraints { $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(38) - $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(14) + $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top).offset(-16) $0.height.equalTo(54) } } @@ -169,6 +166,7 @@ class EmailInputViewController: TFBaseViewController { .disposed(by: disposeBag) let input = EmailInputViewModel.Input( + viewDidAppear: rx.viewDidAppear.asDriver().map { _ in }, emailText: emailTFStrDriver, clearBtnTapped: clearBtn.rx.tap.asDriver(), nextBtnTap: nextButton.rx.tap.asDriver(), @@ -183,10 +181,6 @@ class EmailInputViewController: TFBaseViewController { .drive(nextButton.rx.buttonStatus, nextButton.rx.isEnabled) .disposed(by: disposeBag) - output.buttonTappedResult - .drive() - .disposed(by: disposeBag) - output.emailTextStatus .drive(with: self, onNext: { vc, state in switch state { @@ -222,42 +216,9 @@ class EmailInputViewController: TFBaseViewController { .disposed(by: disposeBag) output.emailText - .drive(emailTextField.rx.text) - .disposed(by: disposeBag) - } - - func keyboardSetting() { - view.rx.tapGesture() - .when(.recognized) - .withUnretained(self) - .subscribe { vc, _ in - vc.view.endEditing(true) - } - .disposed(by: disposeBag) - - RxKeyboard.instance.visibleHeight - .skip(1) - .drive(onNext: { [weak self] keyboardHeight in - guard let self else { return } - if keyboardHeight == 0 { - self.nextButton.snp.updateConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide).inset(14) - } - } else { - self.nextButton.snp.updateConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide).inset(keyboardHeight - self.view.safeAreaInsets.bottom + 14) - } - } - - if keyboardHeight == 0 { - self.titleLable.snp.updateConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide).inset(76) - } - } else { - self.titleLable.snp.updateConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide).inset(20) - } - } + .drive(with: self, onNext: { owner, text in + owner.emailTextField.text = text + owner.emailTextField.sendActions(for: .valueChanged) }) .disposed(by: disposeBag) } diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewModel.swift index b2f483a4..8d430a6f 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewModel.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Email/EmailInputViewModel.swift @@ -11,10 +11,7 @@ import Core import RxSwift import RxCocoa - -protocol EmailInputDelegate: AnyObject { - func emailNextButtonTap() -} +import SignUpInterface final class EmailInputViewModel: ViewModelType { enum EmailTextState { @@ -23,9 +20,8 @@ final class EmailInputViewModel: ViewModelType { case invalid } - weak var delegate: EmailInputDelegate? - struct Input { + let viewDidAppear: Driver let emailText: Driver let clearBtnTapped: Driver let nextBtnTap: Driver @@ -38,12 +34,38 @@ final class EmailInputViewModel: ViewModelType { let buttonState: Driver let warningLblState: Driver let emailTextStatus: Driver - let buttonTappedResult: Driver let emailText: Driver } + weak var delegate: SignUpCoordinatingActionDelegate? + + private var disposeBag = DisposeBag() + private let userInfoUseCase: UserInfoUseCaseInterface + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + func transform(input: Input) -> Output { + + let userinfo = input.viewDidAppear + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let initialEmail = userinfo + .map { $0.email ?? "" } + let text = input.emailText + .distinctUntilChanged() + .debounce(.milliseconds(300)) + .debug() + let autoComplete = Driver .merge([input.naverBtnTapped.map { "@naver.com" }, input.gmailBtnTapped.map { "@gmail.com" }, @@ -53,7 +75,6 @@ final class EmailInputViewModel: ViewModelType { let outputText = Driver.merge(text, autoComplete, input.clearBtnTapped.map { "" }) let emailValidate = outputText - .debug("emailValidate") .map { if $0.isEmpty { return EmailTextState.empty @@ -65,7 +86,6 @@ final class EmailInputViewModel: ViewModelType { } } } - .asObservable() let buttonState = emailValidate .map { @@ -76,7 +96,6 @@ final class EmailInputViewModel: ViewModelType { return true } } - .asDriver(onErrorJustReturn: false) let warningLblState = emailValidate .map { @@ -87,22 +106,30 @@ final class EmailInputViewModel: ViewModelType { return false } } - .asDriver(onErrorJustReturn: false) let emailTextStatus = emailValidate.asDriver(onErrorJustReturn: .empty) + let updatedUserInfo = Driver.combineLatest(userinfo, outputText) { userinfo, email in + var userinfo = userinfo + userinfo.email = email + return userinfo + } + // TODO: Email 로 로그인 문제 생겼을때 계정 복구 진행하는데 저장하는 api 를 찾을수 없음. 추후 저장로직 개발 필요해 보임 - let buttonTappedResult = input.nextBtnTap - .do(onNext: { [weak self] in - self?.delegate?.emailNextButtonTap() + input.nextBtnTap + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(updatedUserInfo) + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtEmail) }) + .disposed(by: disposeBag) return Output( buttonState: buttonState, warningLblState: warningLblState, emailTextStatus: emailTextStatus, - buttonTappedResult: buttonTappedResult, - emailText: outputText + emailText: Driver.merge(outputText, initialEmail) ) } } diff --git a/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerView.swift b/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerView.swift new file mode 100644 index 00000000..94a2a12d --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerView.swift @@ -0,0 +1,110 @@ +// +// GenderPickerView.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit + +import DSKit +import RxSwift +import SignUpInterface + +class GenderPickerView: TFBaseView { + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var genderPickerView = ButtonPickerView( + title: "성별, 생일을 입력해주세요.", targetString: "성별, 생일", + option1: "여자", option2: "남자" + ) + + lazy var birthdayLabel: UILabel = UILabel().then { + $0.textAlignment = .left + $0.font = .thtH2B + $0.textColor = DSKitAsset.Color.neutral400.color + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.text = "입력하신 나이와 성별은 추후 변경할 수 없습니다." + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 1 + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + container.addSubviews( + genderPickerView, + birthdayLabel, + infoImageView, descLabel, + nextBtn + ) + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + genderPickerView.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview() + } + + birthdayLabel.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(30) + $0.top.equalTo(genderPickerView.snp.bottom).offset(10) + $0.height.equalTo(50) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(birthdayLabel.snp.leading) + $0.width.height.equalTo(16) + $0.top.equalTo(birthdayLabel.snp.bottom).offset(16) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.top.equalTo(birthdayLabel.snp.bottom).offset(16) + $0.trailing.equalToSuperview().inset(38) + } + nextBtn.snp.makeConstraints { + $0.top.equalTo(descLabel.snp.bottom).offset(30) + $0.trailing.equalTo(birthdayLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} + +extension Reactive where Base: GenderPickerView { + var selectedGender: Binder { + return Binder(base.self) { view, gender in + view.genderPickerView.handleSelectedState(gender == .female ? .left : .right) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct GenderPickerViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return GenderPickerView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerViewController.swift new file mode 100644 index 00000000..daf8f376 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerViewController.swift @@ -0,0 +1,80 @@ +// +// GenderPickerViewController.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit + +import DSKit + +import SignUpInterface +import RxSwift +import RxCocoa +import RxGesture + +final class GenderPickerViewController: TFBaseViewController { + private let mainView = GenderPickerView() + private let viewModel: GenderPickerViewModel + + init(viewModel: GenderPickerViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + let birthdayTap = self.mainView.birthdayLabel + .rx + .tapGesture() + .when(.recognized) + .map { _ in } + .asDriverOnErrorJustEmpty() + + let genderTap = self.mainView.genderPickerView + .rx.selectedOption + .asDriver() + .compactMap { $0 } + .map { option -> Gender in + switch option { + case .left: + return .female + case .right: + return .male + } + } + + let input = GenderPickerViewModel.Input( + genderTap: genderTap, + birthdayTap: birthdayTap, + nextBtnTap: self.mainView.nextBtn.rx.tap.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.initialGender + .compactMap { $0 } + .drive(self.mainView.rx.selectedGender) + .disposed(by: disposeBag) + + output.birthday + .drive(with: self) { owner, selectedDate in + owner.mainView.birthdayLabel.text = selectedDate + owner.mainView.birthdayLabel.textColor = DSKitAsset.Color.primary500.color + } + .disposed(by: disposeBag) + + output.isNextBtnEnabled + .drive(self.mainView.nextBtn.rx.buttonStatus) + .disposed(by: disposeBag) + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerViewModel.swift new file mode 100644 index 00000000..e72cbbb7 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/GenderPick/GenderPickerViewModel.swift @@ -0,0 +1,96 @@ +// +// GenderPickerViewModel.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/16. +// + +import Foundation + +import Core +import SignUpInterface + +import RxSwift +import RxCocoa + +final class GenderPickerViewModel: ViewModelType { + weak var delegate: SignUpCoordinatingActionDelegate? + + struct Input { + var genderTap: Driver + var birthdayTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var initialGender: Driver + var birthday: Driver + var isNextBtnEnabled: Driver + } + + private var disposeBag = DisposeBag() + + private var selectedBirthday = PublishRelay() + private let userInfoUseCase: UserInfoUseCaseInterface + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + + let userInfo = Observable.just(()) + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let initialGender = userInfo.map { $0.gender } + let initialBirthday = userInfo.map { $0.birthday?.toDate() } + + let selectedGender = Driver.merge(input.genderTap, initialGender.compactMap { $0 }) + let birthday = Driver.merge(self.selectedBirthday.asDriverOnErrorJustEmpty(), initialBirthday) + .map { $0 ?? Date.currentAdultDateOrNil() ?? Date() } + + let nextBtnisEnabled = Driver.zip(selectedGender, birthday).map { _ in true } + + input.birthdayTap + .withLatestFrom(birthday) + .debug("tapped") + .drive(with: self) { owner, birthday in + owner.delegate?.invoke(.birthdayTap(birthday, listener: owner)) + }.disposed(by: disposeBag) + + input.nextBtnTap + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(birthday) + .withLatestFrom(selectedGender) { (date: $0, gender: $1) } + .withLatestFrom(userInfo) { info, userInfo in + var mutable = userInfo + mutable.birthday = info.date.toYMDDotDateString() + mutable.gender = info.gender + return mutable + } + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtGender) + }.disposed(by: disposeBag) + + return Output( + initialGender: initialGender, + birthday: birthday.map { $0.toYMDDotDateString() }, + isNextBtnEnabled: nextBtnisEnabled + ) + } +} + +extension GenderPickerViewModel: BottomSheetListener { + func sendData(item: BottomSheetValueType) { + if case let .date(selectedDate) = item { + self.selectedBirthday.accept(selectedDate) + } + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightPickerView.swift b/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightPickerView.swift new file mode 100644 index 00000000..7e8c7be2 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightPickerView.swift @@ -0,0 +1,103 @@ +// +// HeightView.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import DSKit + +class HeightPickerView: TFBaseView { + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "키을 입력해주세요" + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH1B + $0.asColor(targetString: "키", color: DSKitAsset.Color.neutral50.color) + } + + lazy var heightLabel: UILabel = UILabel().then { + $0.textAlignment = .left + $0.font = .thtH2B + $0.text = "145 cm" + $0.textColor = DSKitAsset.Color.neutral400.color + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.text = "마이페이지에서 변경가능합니다." + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 1 + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + container.addSubviews( + titleLabel, + heightLabel, + infoImageView, descLabel, + nextBtn + ) + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview().inset(30) + } + + heightLabel.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(30) + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.height.equalTo(50) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(heightLabel.snp.leading) + $0.width.height.equalTo(16) + $0.top.equalTo(heightLabel.snp.bottom).offset(16) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.top.equalTo(heightLabel.snp.bottom).offset(16) + $0.trailing.equalToSuperview().inset(38) + } + nextBtn.snp.makeConstraints { + $0.top.equalTo(descLabel.snp.bottom).offset(30) + $0.trailing.equalTo(heightLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct HeightViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return HeightPickerView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightVIewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightVIewController.swift new file mode 100644 index 00000000..926c09e2 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightVIewController.swift @@ -0,0 +1,62 @@ +// +// HeightVIewController.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import DSKit + +import RxSwift +import RxCocoa +import RxGesture + +final class HeightPickerViewController: TFBaseViewController { + private let mainView = HeightPickerView() + private let viewModel: HeightPickerViewModel + + init(viewModel: HeightPickerViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + let pickerLabelTap = self.mainView.heightLabel + .rx + .tapGesture() + .when(.recognized) + .map { _ in } + .asDriverOnErrorJustEmpty() + + let input = HeightPickerViewModel.Input( + pickerLabelTap: pickerLabelTap, + nextBtnTap: self.mainView.nextBtn.rx.tap.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.height + .drive(with: self) { owner, value in + owner.mainView.heightLabel.text = value + owner.mainView.heightLabel.textColor = DSKitAsset.Color.primary500.color + } + .disposed(by: disposeBag) + + output.isNextBtnEnabled + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightVIewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightVIewModel.swift new file mode 100644 index 00000000..847e3dad --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/HeightPicker/HeightVIewModel.swift @@ -0,0 +1,94 @@ +// +// HeightVIewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation + +import Core +import SignUpInterface + +import RxSwift +import RxCocoa + +final class HeightPickerViewModel: ViewModelType { + weak var delegate: SignUpCoordinatingActionDelegate? + + struct Input { + var pickerLabelTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var height: Driver + var isNextBtnEnabled: Driver + } + + private var disposeBag = DisposeBag() + private let userInfoUseCase: UserInfoUseCaseInterface + + private var selectedHeight = BehaviorRelay(value: 145) + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + userinfo + .compactMap { $0.tall } + .drive(selectedHeight) + .disposed(by: disposeBag) + + let height = self.selectedHeight + .asDriver() + + let nextBtnisEnabled = height + .map { _ in return true } + + input.pickerLabelTap + .withLatestFrom(height) + .drive(with: self) { owner, height in + owner.delegate?.invoke(.heightLabelTap(height, listener: owner)) + }.disposed(by: disposeBag) + + input.nextBtnTap + .withLatestFrom(nextBtnisEnabled) + .filter { $0 } + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(height) + .withLatestFrom(userinfo) { height, userinfo in + var mutable = userinfo + mutable.tall = height + return mutable + } + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtHeight) + }.disposed(by: disposeBag) + + return Output( + height: height.map { String($0) + " cm" }, + isNextBtnEnabled: nextBtnisEnabled + ) + } +} + +extension HeightPickerViewModel: BottomSheetListener { + func sendData(item: BottomSheetValueType) { + if case let .text(text) = item { + self.selectedHeight.accept(Int(text) ?? 145) + } + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/IdealTypePicker/IdealTypePickerViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/IdealTypePicker/IdealTypePickerViewController.swift new file mode 100644 index 00000000..c882cb11 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/IdealTypePicker/IdealTypePickerViewController.swift @@ -0,0 +1,59 @@ +// +// IdealTypeViewController.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/24. +// + +import UIKit + +import DSKit + +import RxSwift +import RxCocoa + +final class IdealTypePickerViewController: TFBaseViewController { + typealias VMType = IdealTypeTagPickerViewModel + private(set) var mainView = TagPickerView( + titleInfo: .init(title: "이상형을 알려주세요.", targetText: "이상형"), + subTitleInfo: .init(title: "내 이상형 3개를 선택해주세요.", targetText: "내 이상형") + ) + private let viewModel: VMType + + init(viewModel: VMType) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + + let nextBtnTap = mainView.nextBtn.rx.tap.asDriver() + + let input = VMType.Input( + chipTap: mainView.collectionView.rx.itemSelected.asDriver(), + nextBtnTap: nextBtnTap + ) + + let output = viewModel.transform(input: input) + + output.chips + .drive(mainView.collectionView.rx.items(cellType: InputTagCollectionViewCell.self)) { index, viewModel, cell in + cell.bind(viewModel) + } + .disposed(by: disposeBag) + + output.isNextBtnEnabled + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/IdealTypePicker/IdealTypePickerViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/IdealTypePicker/IdealTypePickerViewModel.swift new file mode 100644 index 00000000..0a28af4b --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/IdealTypePicker/IdealTypePickerViewModel.swift @@ -0,0 +1,122 @@ +// +// IdealTypeViewModel.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/24. +// + +import Foundation + +import Core +import SignUpInterface + +import RxSwift +import RxCocoa + +final class IdealTypeTagPickerViewModel: ViewModelType { + private let useCase: SignUpUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface + weak var delegate: SignUpCoordinatingActionDelegate? + + init(useCase: SignUpUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + } + + struct Input { + var chipTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var chips: Driver<[InputTagItemViewModel]> + var isNextBtnEnabled: Driver + } + + private var disposeBag = DisposeBag() + + func transform(input: Input) -> Output { + + let chips = BehaviorRelay<[InputTagItemViewModel]>(value: []) + + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let local = userinfo.map { $0.idealTypeList } + + let remote = Driver.just(()) + .flatMapLatest { [unowned self] _ in + self.useCase.idealTypes() + .asDriver(onErrorJustReturn: []) + } + .map { $0.map { InputTagItemViewModel(item: $0, isSelected: false) } } + + Driver.zip(local, remote) { local, remote in + var mutable = remote + + local.forEach { selectedIndex in + if let index = mutable.firstIndex(where: { $0.emojiType.idx == selectedIndex }) { + mutable[index].isSelected = true + } + } + return mutable + } + .drive(chips) + .disposed(by: disposeBag) + + input.chipTap.map { $0.item } + .withLatestFrom(chips.asDriver()) { index, chips in + var prev = chips.enumerated().filter { $0.element.isSelected }.map { $0.offset } + + if prev.contains(index) { + prev.removeAll { $0 == index } + } else if prev.count < 3 { + prev.append(index) + } + var mutable = chips.map { + var model = $0 + model.isSelected = false + return model + } + + prev.forEach { index in + mutable[index].isSelected = true + } + + return mutable + }.drive(chips) + .disposed(by: disposeBag) + + let isNextBtnEnabled = chips.asDriver() + .map { $0.filter { $0.isSelected }.count == 3 } + + input.nextBtnTap + .withLatestFrom(isNextBtnEnabled) + .filter { $0 } + .withLatestFrom(chips.asDriver()) { _, chips in + chips.filter { $0.isSelected }.map { $0.emojiType.idx } + } + .withLatestFrom(userinfo) { items, userinfo in + var mutable = userinfo + mutable.idealTypeList = items + return mutable + } + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtIdealType) + }) + .disposed(by: disposeBag) + + return Output( + chips: chips.asDriver(), + isNextBtnEnabled: isNextBtnEnabled + ) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/InterestPicker/InterestPickerViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/InterestPicker/InterestPickerViewController.swift new file mode 100644 index 00000000..2a7f500c --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/InterestPicker/InterestPickerViewController.swift @@ -0,0 +1,59 @@ +// +// InterestPickerViewController.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import DSKit + +import RxSwift +import RxCocoa + +final class InterestPickerViewController: TFBaseViewController { + typealias VMType = TagPickerViewModel + private(set) var mainView = TagPickerView( + titleInfo: .init(title: "관심사를 알려주세요.", targetText: "관심사"), + subTitleInfo: .init(title: "내 관심사 3개를 선택해주세요.", targetText: "내 관심사") + ) + private let viewModel: VMType + + init(viewModel: VMType) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + + let nextBtnTap = mainView.nextBtn.rx.tap.asDriver() + + let input = VMType.Input( + chipTap: mainView.collectionView.rx.itemSelected.asDriver(), + nextBtnTap: nextBtnTap + ) + + let output = viewModel.transform(input: input) + + output.chips + .drive(mainView.collectionView.rx.items(cellType: InputTagCollectionViewCell.self)) { index, viewModel, cell in + cell.bind(viewModel) + } + .disposed(by: disposeBag) + + output.isNextBtnEnabled + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/InterestPicker/TagPickerViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/InterestPicker/TagPickerViewModel.swift new file mode 100644 index 00000000..96e136f6 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/InterestPicker/TagPickerViewModel.swift @@ -0,0 +1,123 @@ +// +// InterestPickerViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation + +import Core +import SignUpInterface + +import RxSwift +import RxCocoa +import Domain + +final class TagPickerViewModel: ViewModelType { + private let useCase: SignUpUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface + weak var delegate: SignUpCoordinatingActionDelegate? + + struct Input { + var chipTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var chips: Driver<[InputTagItemViewModel]> + var isNextBtnEnabled: Driver + } + + private var disposeBag = DisposeBag() + + init(useCase: SignUpUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + + let chips = BehaviorRelay<[InputTagItemViewModel]>(value: []) + + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let local = userinfo.map { $0.interestsList } + + let remote = Driver.just(()) + .flatMapLatest { [unowned self] _ in + self.useCase.interests() + .asDriver(onErrorJustReturn: []) + } + .map { $0.map { InputTagItemViewModel(item: $0, isSelected: false) } } + + Driver.zip(local, remote) { local, remote in + var mutable = remote + + local.forEach { selectedIndex in + if let index = mutable.firstIndex(where: { $0.emojiType.idx == selectedIndex }) { + mutable[index].isSelected = true + } + } + return mutable + } + .drive(chips) + .disposed(by: disposeBag) + + input.chipTap.map { $0.item } + .withLatestFrom(chips.asDriver()) { index, chips in + var prev = chips.enumerated().filter { $0.element.isSelected }.map { $0.offset } + + if prev.contains(index) { + prev.removeAll { $0 == index } + } else if prev.count < 3 { + prev.append(index) + } + var mutable = chips.map { + var model = $0 + model.isSelected = false + return model + } + + prev.forEach { index in + mutable[index].isSelected = true + } + + return mutable + }.drive(chips) + .disposed(by: disposeBag) + + let isNextBtnEnabled = chips.asDriver() + .map { $0.filter { $0.isSelected }.count == 3 } + + input.nextBtnTap + .withLatestFrom(isNextBtnEnabled) + .filter { $0 } + .withLatestFrom(chips.asDriver()) { _, chips in + chips.filter { $0.isSelected }.map { $0.emojiType.idx } + } + .withLatestFrom(userinfo) { items, userinfo in + var mutable = userinfo + mutable.interestsList = items + return mutable + } + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtInterest) + }) + .disposed(by: disposeBag) + + return Output( + chips: chips.asDriver(), + isNextBtnEnabled: isNextBtnEnabled + ) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputView.swift b/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputView.swift new file mode 100644 index 00000000..9a7ca27a --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputView.swift @@ -0,0 +1,80 @@ +// +// IntroduceInputView.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/24. +// + +import UIKit + +import DSKit + +final class IntroduceInputView: TFBaseView { + + lazy var introduceInputView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "나를 알려주세요" + $0.textColor = DSKitAsset.Color.neutral400.color + $0.asColor(targetString: "나를 알려", color: DSKitAsset.Color.neutral50.color) + $0.font = .thtH1B + } + + lazy var introduceInputField = TFResizableTextView( + description: "자유롭게 소개해주세요", + totalCount: 200 + ).then { + $0.placeholder = "저의 MBTI는요" + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(introduceInputView) + + introduceInputView.addSubviews( + titleLabel, + introduceInputField, + nextBtn + ) + + introduceInputView.snp.makeConstraints { + $0.edges.equalTo(safeAreaLayoutGuide) + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(76) + $0.leading.equalToSuperview().inset(38) + } + + introduceInputField.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(32) + $0.leading.trailing.equalToSuperview().inset(38) + } + + nextBtn.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(38) + $0.height.equalTo(54) + $0.width.equalTo(88) + $0.bottom.equalTo(keyboardLayoutGuide.snp.top).offset(-16) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct IntroduceInputViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let view = IntroduceInputView() +// view.introduceInputField.render(state: .text(text: "입력")) + return view + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputViewController.swift new file mode 100644 index 00000000..bd3ac073 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputViewController.swift @@ -0,0 +1,50 @@ +// +// IntroduceInputViewController.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/24. +// + +import UIKit + +import DSKit + +final class IntroduceInputViewController: TFBaseViewController { + + private let viewModel: IntroduceInputViewModel + private lazy var mainView = IntroduceInputView() + + init(viewModel: IntroduceInputViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + + override func bindViewModel() { + super.bindViewModel() + + let input = IntroduceInputViewModel.Input( + nextBtn: self.mainView.nextBtn.rx.tap.asDriver(), + introduceText: self.mainView.introduceInputField.rx.text.orEmpty.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.isEnableNextBtn + .drive(self.mainView.nextBtn.rx.buttonStatus) + .disposed(by: disposeBag) + + output.initialValue + .drive(self.mainView.introduceInputField.rx.text) + .disposed(by: disposeBag) + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputViewModel.swift new file mode 100644 index 00000000..9d0f9e5b --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/IntroduceInput/IntroduceInputViewModel.swift @@ -0,0 +1,73 @@ +// +// IntroduceInputViewModel.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/24. +// + +import Foundation + +import Core + +import RxCocoa +import RxSwift +import SignUpInterface + +final class IntroduceInputViewModel: ViewModelType { + private let userInfoUseCase: UserInfoUseCaseInterface + + struct Input { + let nextBtn: Driver + let introduceText: Driver + } + + struct Output { + let initialValue: Driver + let isEnableNextBtn: Driver + } + + weak var delegate: SignUpCoordinatingActionDelegate? + private let disposeBag = DisposeBag() + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let local = userinfo.map { $0.introduction } + + let isEnableNextBtn = input.introduceText + .map { !$0.isEmpty && $0.count < 201 } + + input.nextBtn + .withLatestFrom(isEnableNextBtn) + .filter{ $0 } + .withLatestFrom(input.introduceText) + .withLatestFrom(userinfo) { text, userinfo in + var mutable = userinfo + mutable.introduction = text + return mutable + } + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtIntroduce) + }) + .disposed(by: disposeBag) + + + return Output( + initialValue: local, + isEnableNextBtn: isEnableNextBtn + ) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputView.swift b/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputView.swift new file mode 100644 index 00000000..33f4e31a --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputView.swift @@ -0,0 +1,80 @@ +// +// LocationInputView.swift +// SignUp +// +// Created by Kanghos on 2024/04/27. +// + +import UIKit + +import DSKit + +final class LocationInputView: TFBaseView { + lazy var containerView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "현재 위치를 알려주세요" + $0.textColor = DSKitAsset.Color.neutral400.color + $0.asColor(targetString: "현재 위치를", color: DSKitAsset.Color.neutral50.color) + $0.font = .thtH1B + } + + lazy var locationField = LocationInputField() + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(containerView) + containerView.addSubviews( + titleLabel, + locationField, + nextBtn + ) + + containerView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(76) + $0.leading.equalToSuperview().inset(38) + } + + locationField.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(32) + $0.leading.trailing.equalToSuperview().inset(38) + } + + // webview -> 주소, 법정동코드 수집 -> KAKAO API 좌표 추가 수집 + // 매니저에서 -> 주소(로드 정확하게), 좌표 -> 벙정동코드 추가 수집 + // 파라미터 저장, 이미지 저장 + // 파라미터로 값 초기화 + + // + + nextBtn.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(38) + $0.height.equalTo(54) + $0.width.equalTo(88) + $0.bottom.equalTo(keyboardLayoutGuide.snp.top).offset(-16) + } + } +} +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct LocationInputViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = LocationInputView() + component.locationField.bind("서울시 성북구 성북동") + return component + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputViewController.swift new file mode 100644 index 00000000..9658b3b9 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputViewController.swift @@ -0,0 +1,55 @@ +// +// LocationInputViewController.swift +// SignUp +// +// Created by Kanghos on 2024/04/27. +// + +import UIKit + +import DSKit +import RxGesture + +final class LocationInputViewController: TFBaseViewController { + typealias ViewModel = LocationInputViewModel + private let mainView = LocationInputView() + private let viewModel: LocationInputViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + + let fieldTap = mainView.locationField.rx + .tapGesture() + .when(.recognized) + .map { _ in } + .asDriverOnErrorJustEmpty() + + let input = ViewModel.Input( + locationBtnTap: fieldTap, + nextBtn: self.mainView.nextBtn.rx.tap.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.isNextBtnEnabled + .drive(self.mainView.nextBtn.rx.buttonStatus) + .disposed(by: disposeBag) + + output.currentLocation + .drive(self.mainView.locationField.rx.location) + .disposed(by: disposeBag) + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputViewModel.swift new file mode 100644 index 00000000..01c25cf1 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Location/LocationInputViewModel.swift @@ -0,0 +1,127 @@ +// +// LocationInputViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/27. +// + +import Foundation + +import Core + +import RxSwift +import RxCocoa +import SignUpInterface + +final class LocationInputViewModel: ViewModelType { + private var disposeBag = DisposeBag() + weak var delegate: SignUpCoordinatingActionDelegate? + + private let locationTrigger = PublishSubject() + private let useCase: SignUpUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface + + struct Input { + let locationBtnTap: Driver + let nextBtn: Driver + } + + struct Output { + let isNextBtnEnabled: Driver + let currentLocation: Driver + } + + init(useCase: SignUpUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + } + + // 필드 클릭하면 퍼미션 리퀘스트 후 -> granted: Bool + // granted 면 시작, 아니면, + + func transform(input: Input) -> Output { + + let addressTrigger = self.locationTrigger + let currentLocation = BehaviorRelay(value: nil) + + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + userinfo.map { $0.address } + .drive(currentLocation) + .disposed(by: disposeBag) + + input.locationBtnTap + .throttle(.milliseconds(500), latest: false) + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, _ in + + return owner.useCase.fetchLocation() + .debug("location usecase") + .catch { error in + print(error.localizedDescription) + owner.delegate?.invoke(.webViewTap(listner: self)) + return .error(error) + } + .asDriver(onErrorDriveWith: .empty()) + } + .asDriverOnErrorJustEmpty() + .drive(currentLocation) + .disposed(by: disposeBag) + + addressTrigger + .asDriverOnErrorJustEmpty() + .flatMap { [unowned self] address in + self.useCase.fetchLocation(address) + .asDriver(onErrorDriveWith: .empty()) + } + .drive(currentLocation) + .disposed(by: disposeBag) + + input.nextBtn + .throttle(.milliseconds(300), latest: false) + .withLatestFrom(currentLocation.asDriver()) + .withLatestFrom(userinfo) { location, userinfo in + var mutable = userinfo + mutable.address = location + return mutable + } + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtLocation) + }) + .disposed(by: disposeBag) + + let isNextBtnEnabled = currentLocation + .map(validator) + .asDriver(onErrorJustReturn: false) + + return Output( + isNextBtnEnabled: isNextBtnEnabled, + currentLocation: currentLocation.compactMap { $0?.address }.asDriverOnErrorJustEmpty() + ) + } + + func validator(_ location: LocationReq?) -> Bool { + guard + let location, + location.address.isEmpty == false, + location.lat != 0, location.lon != 0 + else { return false } + return true + } +} + +extension LocationInputViewModel: WebViewDelegate { + func didReceiveAddress(_ address: String) { + self.locationTrigger.onNext(address) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Location/PostCodeWebViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Location/PostCodeWebViewController.swift new file mode 100644 index 00000000..ad2ebb29 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Location/PostCodeWebViewController.swift @@ -0,0 +1,124 @@ +// +// PostCodeWebViewController.swift +// SignUp +// +// Created by kangho lee on 4/29/24. +// + +import UIKit +import WebKit + +import Core +import DSKit + +public protocol WebViewDelegate: AnyObject { + func didReceiveAddress(_ address: String) +} + +public class PostCodeWebViewController: TFBaseViewController { + + enum WebViewKey: String { + case bridge = "callBackHandler" + case jibunAddress + + } + + // MARK: - Properties + var webView: WKWebView? + let indicator = UIActivityIndicatorView(style: .medium) + var address = "" + + weak var delegate: WebViewDelegate? + + // MARK: - Lifecycle + + public override func makeUI() { + view.backgroundColor = .white + setAttributes() + setContraints() + + } + + private func setAttributes() { + let contentController = WKUserContentController() + contentController.add(self, name: WebViewKey.bridge.rawValue) + + let configuration = WKWebViewConfiguration() + configuration.userContentController = contentController + + webView = WKWebView(frame: .zero, configuration: configuration) + self.webView?.navigationDelegate = self + + guard let url = URL(string: "https://ibcylon.github.io/DaumAPI/"), + let webView = webView + else { return } + let request = URLRequest(url: url) + webView.load(request) + indicator.startAnimating() + } + + private func setContraints() { + guard let webView = webView else { return } + view.addSubview(webView) + view.backgroundColor = .clear + webView.translatesAutoresizingMaskIntoConstraints = false + + webView.addSubview(indicator) + indicator.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor, constant: 200), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -200), + + indicator.centerXAnchor.constraint(equalTo: webView.centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: webView.centerYAnchor), + ]) + } +} + +extension PostCodeWebViewController: WKScriptMessageHandler { + public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == WebViewKey.bridge.rawValue { + if let data = message.body as? [String: Any] { + if let address = data[WebViewKey.jibunAddress.rawValue] as? String { + var formatted = address.components(separatedBy: " ") + var returnValue = "" + if formatted[0] != "서울" { + formatted.removeFirst() + } else { + formatted[0] = "서울시" + } + returnValue = formatted.prefix(3).joined(separator: " ") + self.delegate?.didReceiveAddress(returnValue) + } + } + } + self.dismiss(animated: true, completion: nil) + } +} + +extension PostCodeWebViewController: WKNavigationDelegate { + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + indicator.startAnimating() + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + indicator.stopAnimating() + } +} +// +//#if canImport(SwiftUI) && DEBUG +//import SwiftUI +// +//struct Location_ViewController_Preview: PreviewProvider { +// static var previews: some View { +// let vm = LocationInputViewModel(locationservice: LocationService()) +// let vc = LocationInputViewController(viewModel: vm) +// return vc.showPreview() +// } +//} +//#endif +// +// diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Location/SubView/LocationInputField.swift b/Projects/Features/SignUp/Src/SignUpRoot/Location/SubView/LocationInputField.swift new file mode 100644 index 00000000..55fff209 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Location/SubView/LocationInputField.swift @@ -0,0 +1,146 @@ +// +// LocationInputView.swift +// SignUp +// +// Created by Kanghos on 2024/04/27. +// + +import UIKit + +import DSKit + +class LocationInputField: UIControl { + + private lazy var containView = UIControl().then { + $0.layer.borderWidth = 1 + $0.layer.borderColor = DSKitAsset.Color.neutral300.color.cgColor + $0.clipsToBounds = true + $0.layer.cornerRadius = 12 + } + + private lazy var pinImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.pin.image.withTintColor(DSKitAsset.Color.neutral50.color, renderingMode: .alwaysOriginal) + } + private lazy var locationLabel = UILabel().then { + $0.text = "서울시 강남구 대치동" + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH5M + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 3 + $0.text = "내 주변의 친구들과 더 많은 대화를 나눌 수 있어요." + } + + var location: String = "" { + didSet { + locationLabel.text = location + locationLabel.textColor = DSKitAsset.Color.neutral50.color + self.containView.layer.borderColor = DSKitAsset.Color.primary500.color.cgColor + } + } + + init() { + super.init(frame: .zero) + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func makeUI() { + addSubviews( + containView, + infoImageView, descLabel + ) + + containView.addSubviews( + pinImageView, locationLabel + ) + + containView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + + pinImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(10) + $0.centerY.equalTo(locationLabel) + $0.size.equalTo(30) + } + + locationLabel.snp.makeConstraints { + $0.leading.equalTo(pinImageView.snp.trailing).offset(5) + $0.top.equalToSuperview().offset(16) + $0.bottom.trailing.equalToSuperview().offset(-16) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(containView) + $0.size.equalTo(16) + $0.top.equalTo(containView.snp.bottom).offset(10) + $0.bottom.equalToSuperview() + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.trailing.equalTo(containView) + $0.centerY.equalTo(infoImageView) + } + + let action = UIAction { [weak self] _ in self?.sendActions(for: .touchUpInside) } + + containView.addAction(action, for: .touchUpInside) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapAction)) + self.locationLabel.addGestureRecognizer(tapGesture) + } + + @objc func tapAction() { + sendActions(for: .touchUpInside) + } +} + +extension LocationInputField { + func bind(_ location: String) { + self.location = location + self.containView.layer.borderColor = DSKitAsset.Color.primary500.color.cgColor + self.locationLabel.textColor = DSKitAsset.Color.neutral50.color + } +} + +extension Reactive where Base: LocationInputField { + var location: Binder { + return Binder(self.base) { view, location in + view.bind(location) + } + } + + var tap: ControlEvent { + return controlEvent(.touchUpInside) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct LocationInputFieldPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = LocationInputField() + component.bind("서울시 성북구 성북동") + return component + } + .previewLayout(.sizeThatFits) + .frame(width: 375, height: 100) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknamInputeViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknamInputeViewController.swift index dba5f1b9..d1d256f7 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknamInputeViewController.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknamInputeViewController.swift @@ -13,10 +13,6 @@ final class NicknameInputViewController: TFBaseViewController { fileprivate let mainView = NicknameView() - override func loadView() { - self.view = mainView - } - private let viewModel: NicknameInputViewModel init(viewModel: NicknameInputViewModel) { @@ -28,109 +24,49 @@ final class NicknameInputViewController: TFBaseViewController { fatalError("init(coder:) has not been implemented") } - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + mainView.nicknameInputField.textField.becomeFirstResponder() + } - keyBoardSetting() + override func makeUI() { + view.addSubview(mainView) + mainView.snp.makeConstraints { + $0.edges.equalToSuperview() + } } override func bindViewModel() { let viewWillAppear = rx.sentMessage(#selector(UIViewController.viewWillAppear(_:))) .do(onNext: { [weak self] _ in - self?.mainView.nicknameTextField.becomeFirstResponder() + self?.mainView.nicknameInputField.textField.becomeFirstResponder() }) .map { _ in } .asDriver(onErrorDriveWith: .empty()) let input = NicknameInputViewModel.Input( viewWillAppear: viewWillAppear, - nickname: mainView.nicknameTextField.rx.text.orEmpty.asDriver(), - clearBtn: mainView.clearBtn.rx.tap.asDriver(), + nickname: mainView.nicknameInputField.textField.rx.text.orEmpty.asDriver(), nextBtn: mainView.nextBtn.rx.tap.asDriver() ) let output = viewModel.transform(input: input) -// -// output.phoneNum -// .drive(phoneNumTextField.rx.text) -// .disposed(by: disposeBag) -// -// output.phoneNum -// .map { $0 + "으로\n전송된 코드를 입력해주세요."} -// .drive(codeInputDescLabel.rx.text) -// .disposed(by: disposeBag) -// -// output.validate -// .filter { $0 == true } -// .map { _ in DSKitAsset.Color.primary500.color } -// .drive(verifyBtn.rx.backgroundColor) -// .disposed(by: disposeBag) -// -// output.validate -// .filter { $0 == false } -// .map { _ in DSKitAsset.Color.disabled.color } -// .drive(verifyBtn.rx.backgroundColor) -// .disposed(by: disposeBag) -// -// output.validate -// .map { $0 == true } -// .drive(verifyBtn.rx.isEnabled) -// .disposed(by: disposeBag) -// -// output.error -// .asSignal() -// .emit { -// print($0) -// }.disposed(by: disposeBag) -// -// output.clearButtonTapped -// .drive(phoneNumTextField.rx.text) -// .disposed(by: disposeBag) -// -// -// output.viewStatus -// .map { $0 != .authCode } -// .drive(codeInputView.rx.isHidden) -// .disposed(by: disposeBag) -// -// output.viewStatus -// .map { $0 != .phoneNumber } -// .drive(onNext: { [weak self] in -// guard let self else { return } -// if $0 { -// self.codeInputTextField.becomeFirstResponder() -// } -// }) -// .disposed(by: disposeBag) - -// -// output.navigatorDisposble -// .drive() -// .disposed(by: disposeBag) - } - - func keyBoardSetting() { - view.rx.tapGesture() - .when(.recognized) - .withUnretained(self) - .subscribe { vc, _ in - vc.view.endEditing(true) - } + + output.errorField + .debug("errorField") + .drive(with: self, onNext: { owner, message in + owner.mainView.nicknameInputField.render(state: .error(error: .validate(text: message))) + }) + .disposed(by: disposeBag) + + output.validate + .drive(mainView.nextBtn.rx.buttonStatus) .disposed(by: disposeBag) - RxKeyboard.instance.visibleHeight - .skip(1) - .drive(with: self, onNext: { owner, keyboardHeight in - if keyboardHeight == 0 { - owner.mainView.snp.updateConstraints { - $0.bottom.equalToSuperview().inset(14) - } - } else { - owner.mainView.nextBtn.snp.updateConstraints { - $0.bottom.equalToSuperview().inset(keyboardHeight - owner.view.safeAreaInsets.bottom + 14) - } - } - }) + output.initialValue + .debug("initial") + .drive(mainView.nicknameInputField.rx.text) .disposed(by: disposeBag) } } diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameView.swift b/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameView.swift index 90a8e358..fbb52b59 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameView.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameView.swift @@ -17,97 +17,62 @@ final class NicknameView: TFBaseView { lazy var titleLabel: UILabel = UILabel().then { $0.text = "닉네임을 알려주세요" + $0.textColor = DSKitAsset.Color.neutral400.color + $0.asColor(targetString: "닉네임", color: DSKitAsset.Color.neutral50.color) $0.font = .thtH1B - $0.textColor = DSKitAsset.Color.neutral50.color } - lazy var nicknameTextField: UITextField = UITextField().then { + lazy var nicknameInputField = TFTextField( + description: "폴링에서 활동할 자유로운 호칭을 설정해주세요", + totalCount: 12 + ).then { $0.placeholder = "닉네임" - $0.textColor = DSKitAsset.Color.primary500.color - $0.font = .thtH2B - $0.keyboardType = .numberPad - } - - lazy var clearBtn: UIButton = UIButton().then { - $0.setImage(DSKitAsset.Image.Icons.closeCircle.image, for: .normal) - $0.setTitle(nil, for: .normal) - $0.backgroundColor = .clear } - lazy var divider: UIView = UIView().then { - $0.backgroundColor = DSKitAsset.Color.neutral300.color - } - - lazy var infoImageView: UIImageView = UIImageView().then { - $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) - $0.tintColor = DSKitAsset.Color.neutral400.color - } - - lazy var descLabel: UILabel = UILabel().then { - $0.text = "폴링에서 활동할 자유로운 호칭을 설정해주세요" - $0.font = .thtCaption1M - $0.textColor = DSKitAsset.Color.neutral400.color - $0.textAlignment = .left - $0.numberOfLines = 1 - } - - lazy var nextBtn: UIButton = UIButton().then { - $0.setTitle("화살표", for: .normal) - $0.setTitleColor(DSKitAsset.Color.neutral600.color, for: .normal) - $0.titleLabel?.font = .thtH5B - $0.backgroundColor = DSKitAsset.Color.primary500.color - $0.layer.cornerRadius = 16 - } + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) override func makeUI() { addSubview(nicknameInputView) - nicknameInputView.addSubviews(titleLabel, nicknameTextField, clearBtn, divider, infoImageView, descLabel, nextBtn) + nicknameInputView.addSubviews( + titleLabel, + nicknameInputField, + nextBtn + ) nicknameInputView.snp.makeConstraints { $0.edges.equalTo(safeAreaLayoutGuide) } titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().inset(frame.height * 0.09) + $0.top.equalToSuperview().inset(76) $0.leading.equalToSuperview().inset(38) } - nicknameTextField.snp.makeConstraints { + nicknameInputField.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(32) - $0.leading.equalToSuperview().inset(38) - $0.trailing.equalTo(clearBtn.snp.trailing) + $0.leading.trailing.equalToSuperview().inset(38) } - clearBtn.snp.makeConstraints { - $0.centerY.equalTo(nicknameTextField.snp.centerY) - $0.width.height.equalTo(24) + nextBtn.snp.makeConstraints { $0.trailing.equalToSuperview().inset(38) + $0.height.equalTo(54) + $0.width.equalTo(88) + $0.bottom.equalTo(keyboardLayoutGuide.snp.top).offset(-16) } + } +} - divider.snp.makeConstraints { - $0.leading.equalTo(nicknameTextField.snp.leading) - $0.trailing.equalTo(clearBtn.snp.trailing) - $0.height.equalTo(2) - $0.top.equalTo(nicknameTextField.snp.bottom).offset(2) - } - - infoImageView.snp.makeConstraints { - $0.leading.equalTo(nicknameTextField.snp.leading) - $0.width.height.equalTo(16) - $0.top.equalTo(divider.snp.bottom).offset(16) - } +#if canImport(SwiftUI) && DEBUG +import SwiftUI - descLabel.snp.makeConstraints { - $0.leading.equalTo(infoImageView.snp.trailing).offset(6) - $0.top.equalTo(divider.snp.bottom).offset(16) - $0.trailing.equalToSuperview().inset(38) - } +struct NicknameViewPreview: PreviewProvider { - nextBtn.snp.makeConstraints { - $0.leading.trailing.equalToSuperview().inset(38) - $0.bottom.equalToSuperview().offset(14) - $0.height.equalTo(54) + static var previews: some View { + UIViewPreview { + return NicknameView() } + .previewLayout(.sizeThatFits) } } +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameViewModel.swift index 79c06240..b0b871df 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameViewModel.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Nickname/NicknameViewModel.swift @@ -6,33 +6,112 @@ // import Foundation +import SignUpInterface import Core import RxCocoa import RxSwift -protocol NicknameInputDelegate: AnyObject { - func nicknameNextButtonTap() -} - final class NicknameInputViewModel: ViewModelType { + private let useCase: SignUpUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface - weak var delegate: NicknameInputDelegate? + weak var delegate: SignUpCoordinatingActionDelegate? + + private var disposeBag = DisposeBag() + + init(useCase: SignUpUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + } struct Input { let viewWillAppear: Driver let nickname: Driver - let clearBtn: Driver let nextBtn: Driver } struct Output { - + let initialValue: Driver + let validate: Driver + let errorField: Driver } func transform(input: Input) -> Output { + let text = input.nickname + let errorTracker = PublishSubject() + let outputText = BehaviorRelay(value: nil) + let userinfo = input.viewWillAppear + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let initialNickname = userinfo.map { $0.name } + + let isDuplicate = text + .debounce(.milliseconds(500)) + .distinctUntilChanged() + .filter(validateNickname) + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, text in + owner.useCase.checkNickname(nickname: text) + .asObservable() + .catch({ error in + errorTracker.onNext(error) + return .empty() + }) + } + .asDriverOnErrorJustEmpty() + + let isAvailableNickname = isDuplicate.map { $0 == false } + + isDuplicate + .filter { $0 } + .debug("isDuplicate to ErrorTracker") + .map { _ in SignUpError.duplicateNickname } + .drive(errorTracker) + .disposed(by: disposeBag) + + let updatedUserInfo = Driver.combineLatest(text, userinfo) { + var mutable = $1 + mutable.name = $0 + return mutable + } + + input.nextBtn + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(updatedUserInfo) + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtNickname) + } + .disposed(by: disposeBag) + + let errorField = errorTracker + .compactMap { error in + switch error { + case SignUpError.duplicateNickname: + return "이미 사용중인 닉네임입니다." + default: + return error.localizedDescription + } + }.asDriverOnErrorJustEmpty() + + return Output( + initialValue: initialNickname, + validate: isAvailableNickname, + errorField: errorField + ) + } - return Output() + func validateNickname(_ text: String) -> Bool { + !text.isEmpty && text.count < 13 && text.count > 5 } } diff --git a/Projects/Features/SignUp/Src/SignUpRoot/PhoneNumber/PhoneCertificationViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/PhoneNumber/PhoneCertificationViewModel.swift deleted file mode 100644 index a7c68e1a..00000000 --- a/Projects/Features/SignUp/Src/SignUpRoot/PhoneNumber/PhoneCertificationViewModel.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// PhoneCertificationViewModel.swift -// DSKit -// -// Created by Hoo's MacBookPro on 2023/08/03. -// - -import Foundation - -import SignUpInterface -import DSKit - -protocol PhoneCertificationDelegate: AnyObject { - func finishAuth() -} - -final class PhoneCertificationViewModel: ViewModelType { - - struct Input { - let viewWillAppear: Driver - let phoneNum: Driver - let clearBtn: Driver - let verifyBtn: Driver - let codeInput: Driver - let finishAnimationTrigger: Driver - } - - struct Output { - let phoneNum: Driver - let validate: Driver - let error: PublishRelay - let clearButtonTapped: Driver - let viewStatus: Driver - let certificateSuccess: Driver - let certificateFailuer: Driver - let timeStampLabel: Driver - let timeLabelTextColor: Driver - let navigatorDisposble: Driver - } - - weak var delegate: PhoneCertificationDelegate? - - func transform(input: Input) -> Output { - - let error = PublishRelay() - - let validate = input.phoneNum - .map { $0.phoneNumValidation() } - - let phoneNum = input.phoneNum - - let clearButtonTapped = input.clearBtn - .map { "" }.asDriver() - - let verifyButtonTapped = input.verifyBtn - .throttle(.milliseconds(1500), latest: false) - .asObservable() - .withUnretained(self) - .flatMapLatest { owner, phoneNum in - owner.testApi(pNum: phoneNum) - }.asDriver(onErrorDriveWith: .empty()) - - let authNumber = verifyButtonTapped - .map { AuthCodeWithTimeStamp(authCode: $0.authNumber) } - - let viewStatus = authNumber.map { _ in ViewType.authCode } - .startWith(.phoneNumber) - - let inputtedCode = input.codeInput - .distinctUntilChanged() - .filter { $0.count == 6 } - .withLatestFrom(authNumber) { inputCode, authNumber -> Bool in - guard authNumber.isAvailableCode() else { - return false - } - return inputCode == "\(authNumber.authCode)" - } - .debug() - .asDriver(onErrorJustReturn: false) - - let timer = authNumber - .flatMap { authNumber in - return Observable.interval(.seconds(1), scheduler: MainScheduler.instance) - .filter { _ in authNumber.isAvailableCode() } - .take(until: inputtedCode.filter{ $0 == true } - .asObservable() - ) - .share() - .asDriver(onErrorDriveWith: Driver.empty()) - } - - let timeLabelStr = timer - .withLatestFrom(authNumber) { _, authNumber in - authNumber.timeString - } - .debug("timer") - .asDriver(onErrorJustReturn: "") - - let timerLabelColor = timer - .withLatestFrom(authNumber) { _, authNumber in - if authNumber.isAvailableCode() { - return DSKitAsset.Color.neutral50 - } else { - return DSKitAsset.Color.error - } - } - .asDriver(onErrorJustReturn: DSKitAsset.Color.neutral50) - - let finishAuth = input.finishAnimationTrigger - .do(onNext: { [weak self] _ in - self?.delegate?.finishAuth() - }) - - return Output( - phoneNum: phoneNum, - validate: validate, - error: error, - clearButtonTapped: clearButtonTapped, - viewStatus: viewStatus, - certificateSuccess: inputtedCode.filter { $0 == true }, - certificateFailuer: inputtedCode.filter { $0 == false }, - timeStampLabel: timeLabelStr, - timeLabelTextColor: timerLabelColor, - navigatorDisposble: finishAuth - ) - } -} - -// MARK: Test Code -extension PhoneCertificationViewModel { - func testApi(pNum: String) -> Observable { - return Single - .just(PhoneValidationResponse(phoneNumber: pNum, authNumber: 123456)) - .asObservable() - } - - enum ViewType { - case phoneNumber - case authCode - } - - struct AuthCodeWithTimeStamp { - let authCode: Int - let timeStamp = Date.now - var timeString: String { - let timeInterval = timeStamp.timeIntervalSinceNow - let min = abs(Int(timeInterval.truncatingRemainder(dividingBy: 3600)) / 60) - let sec = abs(Int(timeInterval.truncatingRemainder(dividingBy: 60))) - return String(format: "%02d:%02d", min, sec) - } - init(authCode: Int) { - self.authCode = authCode - } - - func isAvailableCode() -> Bool { - let timeInterval = timeStamp.timeIntervalSinceNow - let sec = Int(timeInterval) - return abs(sec) <= 180 - } - } -} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoCell/PhotoCell.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoCell/PhotoCell.swift new file mode 100644 index 00000000..b02d2e2e --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoCell/PhotoCell.swift @@ -0,0 +1,63 @@ +// +// PhotoCell.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import DSKit + +import RxCocoa +import RxSwift + +final class PhotoCell: TFBaseCollectionViewCell { + private var image: UIImage? + + lazy var imageView = UIImageView().then { + $0.layer.cornerRadius = 14 + $0.contentMode = .scaleAspectFill + } + + lazy var addButton = UIButton.plusButton + + override func makeUI() { + contentView.addSubview(addButton) + contentView.addSubview(imageView) + + contentView.layer.borderColor = DSKitAsset.Color.primary500.color.cgColor + contentView.layer.borderWidth = 2 + contentView.layer.masksToBounds = true + contentView.layer.cornerRadius = 10 + + imageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + addButton.snp.makeConstraints { + $0.width.equalTo(52) + $0.height.equalTo(24) + $0.center.equalToSuperview() + } + } + + func bind(_ viewModel: PhotoCellViewModel) { + if let data = viewModel.data { + self.imageView.image = UIImage(data: data) + } else { + self.imageView.image = nil + } + + contentView.layer.borderColor = viewModel.cellType == .required + ? DSKitAsset.Color.primary500.color.cgColor + : DSKitAsset.Color.neutral400.color.cgColor + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.imageView.image = nil + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoCell/PhotoCellViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoCell/PhotoCellViewModel.swift new file mode 100644 index 00000000..a71593a9 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoCell/PhotoCellViewModel.swift @@ -0,0 +1,25 @@ +// +// PhotoCellViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation + +struct PhotoCellViewModel { + enum CellType { + case required + case optional + } + + let id = UUID() + var data: Data? + let cellType: CellType +} + +extension PhotoCellViewModel: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputView.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputView.swift new file mode 100644 index 00000000..5dbf9eeb --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputView.swift @@ -0,0 +1,113 @@ +// +// PhotoInputView.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit + +import DSKit + + +final class PhotoInputView: TFBaseView { + + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "사진을 추가해주세요" + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH1B + $0.asColor(targetString: "사진", color: DSKitAsset.Color.neutral50.color) + } + + lazy var photoCollectionView = UICollectionView(frame: .zero, collectionViewLayout: .photoPickLayout()) + + lazy var contentVStackView = UIStackView().then { + $0.axis = .vertical + $0.distribution = .fill + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.text = "얼굴이 잘 나온 사진 2장을 필수로 넣어주세요." + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 1 + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + photoCollectionView.backgroundColor = .clear + + container.addSubviews( + titleLabel, + contentVStackView, + infoImageView, descLabel, + nextBtn + ) + + contentVStackView.addArrangedSubview(photoCollectionView) + + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview().inset(30) + } + + contentVStackView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(30) + $0.leading.trailing.equalTo(titleLabel) + } + + photoCollectionView.snp.makeConstraints { + $0.height.equalTo(contentVStackView.snp.width) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(titleLabel.snp.leading) + $0.width.height.equalTo(16) + $0.top.equalTo(contentVStackView.snp.bottom).offset(10) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.top.equalTo(infoImageView) + $0.trailing.equalTo(titleLabel) + } + + nextBtn.snp.makeConstraints { + $0.top.equalTo(descLabel.snp.bottom).offset(30) + $0.trailing.equalTo(descLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PhotoInputViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return PhotoInputView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewController+Diff.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewController+Diff.swift new file mode 100644 index 00000000..178a05ee --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewController+Diff.swift @@ -0,0 +1,44 @@ +// +// PhotoInputViewController+Diff.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +extension PhotoInputViewController { + enum Section { + case main + } + + typealias CellType = PhotoCell + typealias SectionType = Section + typealias ModelType = PhotoCellViewModel + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + func configureDataSource() { + let cellRegistration = UICollectionView.CellRegistration { (cell, indexPath, model) in + cell.bind(model) + } + dataSource = UICollectionViewDiffableDataSource(collectionView: mainView.photoCollectionView) { + (collectionView: UICollectionView, indexPath: IndexPath, model: ModelType) -> UICollectionViewCell? in + // Return the cell. + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model) + } + + // initial data + var snapshot = Snapshot() + snapshot.appendSections([Section.main]) + snapshot.appendItems([]) + dataSource.apply(snapshot, animatingDifferences: false) + } + + func updateSnapshot(items: [ModelType]) { + var snapshot = Snapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items) + dataSource.applySnapshotUsingReloadData(snapshot) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewController.swift new file mode 100644 index 00000000..6e1af4ce --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewController.swift @@ -0,0 +1,91 @@ +// +// PhotoInputViewController.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit +import PhotosUI + +import DSKit + +import RxSwift +import RxCocoa + +final class PhotoInputViewController: TFBaseViewController { + var dataSource: DataSource! + private(set) var mainView = PhotoInputView() + private let viewModel: PhotoInputViewModel + + init(viewModel: PhotoInputViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func makeUI() { + configureDataSource() + mainView.photoCollectionView.backgroundColor = .clear + } + + override func bindViewModel() { + + let requiredCell = mainView.photoCollectionView.rx.itemSelected + .asDriver() + .filter { $0.item < 2 } + let optionalCell = mainView.photoCollectionView.rx.itemSelected + .filter { $0.item == 2 } + + let alertTrigger = optionalCell + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, indexPath in + return Observable.create { observer in + let alert = UIAlertController(title: "사진 수정하기", + message: "", + preferredStyle: .actionSheet + ) + let editAction = UIAlertAction(title: "수정", style: .default, handler: { _ -> () in observer.onNext(.edit(indexPath)) }) + let deleteAction = UIAlertAction(title: "제거", style: .destructive, handler: { _ -> () in observer.onNext(.delete(indexPath)) }) + let noAction = UIAlertAction(title: "취소", style: .cancel, handler: { _ -> () in + observer.onCompleted() + }) + alert.addAction(editAction) + alert.addAction(deleteAction) + alert.addAction(noAction) + + owner.present(alert, animated: true, completion: nil) + return Disposables.create { + owner.dismiss() + } + } + }.asDriverOnErrorJustEmpty() + + let nextBtnTap = mainView.nextBtn.rx.tap.asDriver() + + let input = PhotoInputViewModel.Input( + cellTap: requiredCell, alertTap: alertTrigger, + nextBtnTap: nextBtnTap + ) + + let output = viewModel.transform(input: input) + + output.images + .drive(with: self) { owner, items in + owner.updateSnapshot(items: items) + }.disposed(by: disposeBag) + output.nextBtn + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewModel.swift new file mode 100644 index 00000000..9defcaff --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoInputViewModel.swift @@ -0,0 +1,192 @@ +// +// PhotoInputViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit +import PhotosUI + +import Core + +import RxSwift +import RxCocoa +import SignUpInterface + +enum PhotoAlertAction { + case edit(IndexPath) + case delete(IndexPath) +} + +public protocol PhotoPickerListener: AnyObject { + func picker(didFinishPicking results: [PHPickerResult]) +} + +final class PhotoInputViewModel: ViewModelType { + weak var delegate: SignUpCoordinatingActionDelegate? + var pickerDelegate: PhotoPickerDelegate? + var service: PHPickerHandler = PhotoService() + + struct Input { + let cellTap: Driver + let alertTap: Driver + let nextBtnTap: Driver + } + + struct Output { + let images: Driver<[PhotoCellViewModel]> + let nextBtn: Driver + } + + private let userInfoUseCase: UserInfoUseCaseInterface + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + deinit { + print("deinit: PhotoInputViewModel") + } + + private let selectedPHResult = PublishSubject() + private var disposeBag = DisposeBag() + + func transform(input: Input) -> Output { + let imageDataArray = BehaviorRelay<[PhotoCellViewModel]>(value: [ + PhotoCellViewModel(data: nil, cellType: .required), + PhotoCellViewModel(data: nil, cellType: .required), + PhotoCellViewModel(data: nil, cellType: .optional) + ]) + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + userinfo.map { (key: $0.phoneNumber,fileNames: $0.photos) } + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, components in + owner.userInfoUseCase.fetchUserPhotos(key: components.key, fileNames: components.fileNames) + .catchAndReturn([]) + .asObservable() + } + .debug("fetched photo fileURLs") + .withLatestFrom(imageDataArray) { dataArray, models in + var mutable = models + for (index, data) in dataArray.enumerated() { + mutable[index].data = data + } + return mutable + } + .asDriverOnErrorJustEmpty() + .drive(imageDataArray) + .disposed(by: disposeBag) + + let alertEditAction = input.alertTap + .compactMap { action -> IndexPath? in + switch action { + case let .edit(indexPath): + return indexPath + case .delete: + return nil + } + } + + input.alertTap + .compactMap { action in + if case let .delete(indexPath) = action { + return indexPath.item + } + return nil + }.drive(onNext: { item in + var mutable = imageDataArray.value + mutable[item].data = nil + imageDataArray.accept(mutable) + }).disposed(by: disposeBag) + + let index = Driver.merge(input.cellTap, alertEditAction) + .map { + $0.item + } + .do(onNext: { [weak self] index in + guard let self = self else { return } + let pickerDelegate = PhotoPickerDelegator() + pickerDelegate.listener = self + self.pickerDelegate = pickerDelegate + self.delegate?.invoke(.photoCellTap(index: index, listener: pickerDelegate)) + }) + + let data = self.selectedPHResult + .withUnretained(self) + .flatMapLatest { owner, asset -> Driver in + owner.service.bind(asset) + .debug("image") + .asDriver(onErrorDriveWith: .empty()) + } + .asDriverOnErrorJustEmpty() + + Driver.zip(index, data) { index, data in + var mutable = imageDataArray.value + mutable[index].data = data + return mutable + }.drive(imageDataArray) + .disposed(by: disposeBag) + + let nextBtnStatus = imageDataArray + .map { $0.prefix(2) } + .map { cells in + for cell in cells { + if cell.data == nil { + return false + } + } + return true + } + .asDriver(onErrorJustReturn: false) + + input.nextBtnTap + .withLatestFrom(nextBtnStatus) + .filter { $0 } + .throttle(.milliseconds(400), latest: false) + .asObservable() + .withLatestFrom(imageDataArray) + .map { $0.compactMap { $0.data } } + .withLatestFrom(userinfo) { (key: $1.phoneNumber, datas: $0) } + .withUnretained(self) + .flatMapLatest { owner, components in + owner.userInfoUseCase.saveUserPhotos(key: components.key, datas: components.datas) + .catchAndReturn([]) + .asObservable() + } + .debug("saved filed URLS:") + .withLatestFrom(userinfo) { urls, userinfo in + var mutable = userinfo + mutable.photos = urls + return mutable + } + .asDriverOnErrorJustEmpty() + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtPhoto) + }.disposed(by: disposeBag) + + return Output( + images: imageDataArray.asDriver(), + nextBtn: nextBtnStatus + ) + } +} + +extension PhotoInputViewModel: PhotoPickerListener { + func picker(didFinishPicking results: [PHPickerResult]) { + if let item = results.first { + self.selectedPHResult.onNext(item) + } + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoPicker.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoPicker.swift new file mode 100644 index 00000000..b66a665f --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoPicker.swift @@ -0,0 +1,221 @@ +// +// PhotoPicker.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit +import PhotosUI +import AVKit + +class ViewController: UIViewController { + + @IBOutlet weak var progressView: UIProgressView! + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var livePhotoView: PHLivePhotoView! { + didSet { + livePhotoView.contentMode = .scaleAspectFit + } + } + @IBOutlet weak var playButton: UIButton! + private var playButtonVideoURL: URL? + + private var selection = [String: PHPickerResult]() + private var selectedAssetIdentifiers = [String]() + private var selectedAssetIdentifierIterator: IndexingIterator<[String]>? + private var currentAssetIdentifier: String? + + @IBAction func presentPickerForImagesAndVideos(_ sender: Any) { + presentPicker(filter: nil) + } + + @IBAction func presentPickerForImagesIncludingLivePhotos(_ sender: Any) { + presentPicker(filter: PHPickerFilter.images) + } + + @IBAction func presentPickerForLivePhotosOnly(_ sender: Any) { + presentPicker(filter: PHPickerFilter.livePhotos) + } + + /// - Tag: PresentPicker + private func presentPicker(filter: PHPickerFilter?) { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + + // Set the filter type according to the user’s selection. + configuration.filter = filter + // Set the mode to avoid transcoding, if possible, if your app supports arbitrary image/video encodings. + configuration.preferredAssetRepresentationMode = .current + // Set the selection behavior to respect the user’s selection order. + configuration.selection = .ordered + // Set the selection limit to enable multiselection. + configuration.selectionLimit = 0 + // Set the preselected asset identifiers with the identifiers that the app tracks. + configuration.preselectedAssetIdentifiers = selectedAssetIdentifiers + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + displayNext() + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if let viewController = segue.destination as? AVPlayerViewController, let videoURL = playButtonVideoURL { + viewController.player = AVPlayer(url: videoURL) + viewController.player?.play() + } + } +} + +private extension ViewController { + + /// - Tag: LoadItemProvider + func displayNext() { + guard let assetIdentifier = selectedAssetIdentifierIterator?.next() else { return } + currentAssetIdentifier = assetIdentifier + + let progress: Progress? + let itemProvider = selection[assetIdentifier]!.itemProvider + if itemProvider.canLoadObject(ofClass: PHLivePhoto.self) { + progress = itemProvider.loadObject(ofClass: PHLivePhoto.self) { [weak self] livePhoto, error in + DispatchQueue.main.async { + self?.handleCompletion(assetIdentifier: assetIdentifier, object: livePhoto, error: error) + } + } + } + else if itemProvider.canLoadObject(ofClass: UIImage.self) { + progress = itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + DispatchQueue.main.async { + self?.handleCompletion(assetIdentifier: assetIdentifier, object: image, error: error) + } + } + } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + progress = itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, error in + do { + guard let url = url, error == nil else { + throw error ?? NSError(domain: NSFileProviderErrorDomain, code: -1, userInfo: nil) + } + let localURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + try? FileManager.default.removeItem(at: localURL) + try FileManager.default.copyItem(at: url, to: localURL) + DispatchQueue.main.async { + self?.handleCompletion(assetIdentifier: assetIdentifier, object: localURL) + } + } catch let catchedError { + DispatchQueue.main.async { + self?.handleCompletion(assetIdentifier: assetIdentifier, object: nil, error: catchedError) + } + } + } + } else { + progress = nil + } + + displayProgress(progress) + } + + func handleCompletion(assetIdentifier: String, object: Any?, error: Error? = nil) { + guard currentAssetIdentifier == assetIdentifier else { return } + if let livePhoto = object as? PHLivePhoto { + displayLivePhoto(livePhoto) + } else if let image = object as? UIImage { + displayImage(image) + } else if let url = object as? URL { + displayVideoPlayButton(forURL: url) + } else if let error = error { + print("Couldn't display \(assetIdentifier) with error: \(error)") + displayErrorImage() + } else { + displayUnknownImage() + } + } + +} + +private extension ViewController { + + func displayEmptyImage() { + displayImage(UIImage(systemName: "photo.on.rectangle.angled")) + } + + func displayErrorImage() { + displayImage(UIImage(systemName: "exclamationmark.circle")) + } + + func displayUnknownImage() { + displayImage(UIImage(systemName: "questionmark.circle")) + } + + func displayProgress(_ progress: Progress?) { + imageView.image = nil + imageView.isHidden = true + livePhotoView.livePhoto = nil + livePhotoView.isHidden = true + playButtonVideoURL = nil + playButton.isHidden = true + progressView.observedProgress = progress + progressView.isHidden = progress == nil + } + + func displayVideoPlayButton(forURL videoURL: URL?) { + imageView.image = nil + imageView.isHidden = true + livePhotoView.livePhoto = nil + livePhotoView.isHidden = true + playButtonVideoURL = videoURL + playButton.isHidden = videoURL == nil + progressView.observedProgress = nil + progressView.isHidden = true + } + + func displayLivePhoto(_ livePhoto: PHLivePhoto?) { + imageView.image = nil + imageView.isHidden = true + livePhotoView.livePhoto = livePhoto + livePhotoView.isHidden = livePhoto == nil + playButtonVideoURL = nil + playButton.isHidden = true + progressView.observedProgress = nil + progressView.isHidden = true + } + + func displayImage(_ image: UIImage?) { + imageView.image = image + imageView.isHidden = image == nil + livePhotoView.livePhoto = nil + livePhotoView.isHidden = true + playButtonVideoURL = nil + playButton.isHidden = true + progressView.observedProgress = nil + progressView.isHidden = true + } + +} + +extension ViewController: PHPickerViewControllerDelegate { + /// - Tag: ParsePickerResults + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismiss(animated: true) + + let existingSelection = self.selection + var newSelection = [String: PHPickerResult]() + for result in results { + let identifier = result.assetIdentifier! + newSelection[identifier] = existingSelection[identifier] ?? result + } + + // Track the selection in case the user deselects it later. + selection = newSelection + selectedAssetIdentifiers = results.map(\.assetIdentifier!) + selectedAssetIdentifierIterator = selectedAssetIdentifiers.makeIterator() + + if selection.isEmpty { + displayEmptyImage() + } else { + displayNext() + } + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoService.swift b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoService.swift new file mode 100644 index 00000000..3f27650a --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Photo/PhotoService.swift @@ -0,0 +1,42 @@ +// +// PhotoService.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit +import PhotosUI + +import RxSwift + +enum AssetError: Error { + case invalidAsset + case cannotLoad +} + +protocol PHPickerHandler { + func bind(_ asset: PHPickerResult) -> Single +} + +final class PhotoService: PHPickerHandler { + func bind(_ asset: PHPickerResult) -> Single { + return .create { observer in + let itemProvider = asset.itemProvider + if itemProvider.canLoadObject(ofClass: UIImage.self) { + itemProvider.loadObject(ofClass: UIImage.self) { item, error in + if let image = item as? UIImage, + let imageData = image.jpegData(compressionQuality: 1.0) + { + observer(.success(imageData)) + } else { + observer(.failure(error ?? AssetError.invalidAsset)) + } + } + } else { + observer(.failure(AssetError.invalidAsset)) + } + return Disposables.create { } + } + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementView.swift b/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementView.swift index 799fa1e1..437ea3bb 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementView.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementView.swift @@ -8,33 +8,32 @@ import UIKit import DSKit +import SignUpInterface final class PolicyAgreementView: TFBaseView { private lazy var logoView: UIImageView = UIImageView().then { $0.image = DSKitAsset.Image.Component.fallingLogo.image } - lazy var selectAllBtn: UIButton = UIButton().then { - $0.setImage(DSKitAsset.Image.Component.checkCir.image, for: .normal) - $0.setTitle("전체 동의", for: .normal) - $0.setTitleColor(DSKitAsset.Color.neutral50.color, for: .normal) - $0.titleLabel?.font = .thtH5B - $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: -7, bottom: 0, right: 7) - $0.titleEdgeInsets = UIEdgeInsets(top: 0, left: 7, bottom: 0, right: -7) - } - - private lazy var serviceRowsView = UIStackView().then { - $0.axis = .vertical - $0.spacing = 20 + private lazy var titleLabel = UILabel().then { + $0.text = "폴링을 이용하려면 동의가 필요해요." + $0.font = .thtH5Sb + $0.textColor = DSKitAsset.Color.neutral50.color } - - lazy var termsOfServiceRow = ServiceAgreementRowView(serviceType: .termsOfServie) - lazy var privacyPolicyRow = ServiceAgreementRowView(serviceType: .privacyPolicy) + lazy var selectAllBtn = TFCheckButton(btnTitle: "전체 동의", initialStatus: false).then { - lazy var locationServiceRow = ServiceAgreementRowView(serviceType: .locationService) + $0.titleLabel?.font = .thtH5B + } - lazy var marketingServiceRow = ServiceAgreementRowView(serviceType: .marketingService) + private(set) lazy var tableView = UITableView().then { + $0.separatorStyle = .none + $0.backgroundColor = .clear + $0.register(cellType: ServiceAgreementRowView.self) + $0.estimatedRowHeight = 110 + $0.rowHeight = UITableView.automaticDimension + $0.allowsSelection = true + } lazy var nextBtn = CTAButton(btnTitle: "시작하기", initialStatus: false) @@ -50,8 +49,7 @@ final class PolicyAgreementView: TFBaseView { } override func makeUI() { - addSubviews([logoView, selectAllBtn, serviceRowsView, nextBtn, discriptionText]) - + addSubviews([logoView, titleLabel, selectAllBtn, tableView, nextBtn, discriptionText]) logoView.snp.makeConstraints { $0.leading.equalToSuperview().inset(30) @@ -60,22 +58,25 @@ final class PolicyAgreementView: TFBaseView { $0.width.equalTo(77) } - selectAllBtn.snp.makeConstraints { - $0.leading.equalToSuperview().inset(30) - $0.top.equalTo(logoView.snp.bottom).offset(83) + titleLabel.snp.makeConstraints { + $0.top.equalTo(logoView.snp.bottom).offset(30) + $0.leading.equalTo(logoView) + $0.trailing.equalToSuperview() + $0.height.equalTo(40) } - serviceRowsView.snp.makeConstraints { + tableView.snp.makeConstraints { $0.leading.equalToSuperview().inset(30) $0.trailing.equalToSuperview().inset(28) - $0.top.equalTo(selectAllBtn.snp.bottom).offset(34) + $0.top.equalTo(titleLabel.snp.bottom).offset(34) + $0.height.lessThanOrEqualTo(400).priority(.low) + $0.bottom.equalTo(selectAllBtn.snp.top).offset(20) } - serviceRowsView.addArrangedSubviews([termsOfServiceRow, privacyPolicyRow, locationServiceRow, marketingServiceRow]) - - discriptionText.snp.makeConstraints { - $0.bottom.equalToSuperview().inset(61) - $0.leading.equalToSuperview().offset(38) + selectAllBtn.snp.makeConstraints { + $0.leading.trailing.equalTo(nextBtn) + $0.bottom.equalTo(nextBtn.snp.top).offset(-20) + $0.height.equalTo(54) } nextBtn.snp.makeConstraints { @@ -83,8 +84,23 @@ final class PolicyAgreementView: TFBaseView { $0.leading.trailing.equalToSuperview().inset(38) $0.height.equalTo(54) } + discriptionText.snp.makeConstraints { + $0.bottom.equalToSuperview().inset(61) + $0.leading.equalToSuperview().offset(38) + } + } +} +#if canImport(SwiftUI) && DEBUG +import SwiftUI - } +struct TFCheckButtonwPreview: PreviewProvider { + static var previews: some View { + UIViewPreview { + PolicyAgreementView() + } + .previewLayout(.sizeThatFits) + } } +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewController.swift index 397648c7..ad92e222 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewController.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewController.swift @@ -32,51 +32,43 @@ final class PolicyAgreementViewController: TFBaseViewController { } override func bindViewModel() { + let cellAction = PublishRelay<(IndexPath, PolicyAgreementViewModel.CellAction)>() + + customView.tableView.rx.itemSelected.asDriver() + .do(onNext: { [weak self] IndexPath in +// self?.customView.tableView.deselectRow(at: IndexPath, animated: true) + }) + .drive(onNext: { indexPath in + cellAction.accept((indexPath, .Agree)) + }).disposed(by: disposeBag) + let input = PolicyAgreementViewModel.Input( + viewDidAppear: rx.viewDidAppear.asDriver().map { _ in }, agreeAllBtn: customView.selectAllBtn.rx.tap.asDriver(), - tosAgreeBtn: customView.termsOfServiceRow.agreeBtn.rx.tap.asDriver(), - showTosDetailBtn: customView.termsOfServiceRow.goWebviewBtn.rx.tap.asDriver(), - privacyAgreeBtn: customView.privacyPolicyRow.agreeBtn.rx.tap.asDriver(), - showPrivacyDetailBtn: customView.privacyPolicyRow.goWebviewBtn.rx.tap.asDriver(), - locationServiceAgreeBtn: customView.locationServiceRow.agreeBtn.rx.tap.asDriver(), - showLocationServiceDetailBtn: customView.locationServiceRow.goWebviewBtn.rx.tap.asDriver(), - marketingServiceAgreeBtn: customView.marketingServiceRow.agreeBtn.rx.tap.asDriver(), + cellTap: cellAction.asDriverOnErrorJustEmpty(), nextBtn: customView.nextBtn.rx.tap.asDriver() ) let output = viewModel.transform(input: input) - output.agreeAllRowImage - .map { $0.image } - .drive(customView.selectAllBtn.rx.image()) - .disposed(by: disposeBag) - - output.tosAgreeRowImage - .map { $0.image } - .drive(customView.termsOfServiceRow.agreeBtn.rx.image()) - .disposed(by: disposeBag) - - output.privacyAgreeRowImage - .map { $0.image } - .drive(customView.privacyPolicyRow.agreeBtn.rx.image()) - .disposed(by: disposeBag) - - output.locationServiceAgreeRowImage - .map { $0.image } - .drive(customView.locationServiceRow.agreeBtn.rx.image()) + output.cellViewModels + .drive(customView.tableView.rx.items(cellType: ServiceAgreementRowView.self)) { index, viewModel, cell in + cell.bind(viewModel) + cell.agreeBtnOnCliek = { + cellAction.accept((IndexPath(row: index, section: 0), .Agree)) + } + cell.goWebviewBtnOnClick = { + cellAction.accept((IndexPath(row: index, section: 0), .WebView)) + } + } .disposed(by: disposeBag) - output.marketingServiceRowImage - .map { $0.image } - .drive(customView.marketingServiceRow.agreeBtn.rx.image()) + output.agreeAllBtnStatus + .drive(customView.selectAllBtn.rx.buttonStatus) .disposed(by: disposeBag) output.nextBtnStatus .drive(customView.nextBtn.rx.buttonStatus) .disposed(by: disposeBag) - - output.nextButtonTap - .drive() - .disposed(by: disposeBag) } } diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewModel.swift index cf1ced04..5b4a1fe9 100644 --- a/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewModel.swift +++ b/Projects/Features/SignUp/Src/SignUpRoot/Policy/PolicyAgreementViewModel.swift @@ -6,38 +6,29 @@ // import Foundation - +import SignUpInterface import DSKit -protocol PolicyAgreementDelegate: AnyObject { - func policyNextButtonTap() -} - final class PolicyAgreementViewModel: ViewModelType { - - weak var delegate: PolicyAgreementDelegate? + + weak var delegate: SignUpCoordinatingActionDelegate? + + enum CellAction { + case Agree + case WebView + } struct Input { + let viewDidAppear: Driver let agreeAllBtn: Driver - let tosAgreeBtn: Driver - let showTosDetailBtn: Driver - let privacyAgreeBtn: Driver - let showPrivacyDetailBtn: Driver - let locationServiceAgreeBtn: Driver - let showLocationServiceDetailBtn: Driver - let marketingServiceAgreeBtn: Driver + let cellTap: Driver<(IndexPath, CellAction)> let nextBtn: Driver } struct Output { - let agreeAllRowImage: Driver - let tosAgreeRowImage: Driver - let privacyAgreeRowImage: Driver - let locationServiceAgreeRowImage: Driver - let marketingServiceRowImage: Driver + let agreeAllBtnStatus: Driver + let cellViewModels: Driver<[ServiceAgreementRowViewModel]> let nextBtnStatus: Driver - let nextButtonTap: Driver - let disposeble: Disposable } struct AgreeStatus { @@ -57,96 +48,144 @@ final class PolicyAgreementViewModel: ViewModelType { } } - func transform(input: Input) -> Output { - let agreeStatus = BehaviorRelay(value: AgreeStatus( - tos: false, privacy: false, location: false, marketing: false - )) + private let useCase: SignUpUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface + private var disposeBag = DisposeBag() - let totalStatus = input.agreeAllBtn - .withLatestFrom(agreeStatus.asDriver()) { _, status in - return status.reverse() - } - .do(onNext: { agreeStatus.accept($0) }) + init(useCase: SignUpUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + } - let tosStatus = input.tosAgreeBtn - .withLatestFrom(agreeStatus.asDriver()) { _, status in - var mutable = status - mutable.tos.toggle() - return mutable + func transform(input: Input) -> Output { + let agreeStates = BehaviorRelay<[ServiceAgreementRowViewModel]>(value: []) + let webViewTrigger = PublishRelay() + + let userinfo = input.viewDidAppear + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() } - .do { agreeStatus.accept($0) } + .asDriverOnErrorJustEmpty() - let privacyStatus = input.privacyAgreeBtn - .withLatestFrom(agreeStatus.asDriver()) { _, status in - var mutable = status - mutable.privacy.toggle() - return mutable - } - .do { agreeStatus.accept($0) } + let localAgreements = userinfo.map { $0.userAgreements } - let locationStatus = input.locationServiceAgreeBtn - .withLatestFrom(agreeStatus.asDriver()) { _, status in - var mutable = status - mutable.location.toggle() - return mutable + let remoteAgreements = input.viewDidAppear + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.useCase.fetchAgreements() + .catchAndReturn([]) + .asObservable() + } + .asDriver(onErrorJustReturn: []) + .map { array in + array.map { + ServiceAgreementRowViewModel.init(model: $0, checkImage: DSKitAsset.Image.Component.check) + } + } + Driver.zip(localAgreements, remoteAgreements) { local, remote in + guard let local else { return remote } + var mutableRemoteArray = remote + + local.forEach { key, value in + if let index = mutableRemoteArray.firstIndex (where: { $0.model.name == key }) { + mutableRemoteArray[index].checkImage = value ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check + } + } + return mutableRemoteArray + }.drive(agreeStates) + .disposed(by: disposeBag) + + input.cellTap + .withLatestFrom(agreeStates.asDriver()) { cellAction, rows in + let (indexPath, action) = cellAction + var rows = rows + var row = rows[indexPath.row] + + switch action { + case .Agree: + print(row.checkImage.name) + row.checkImage = row.checkImage.name == DSKitAsset.Image.Component.check.name ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check + rows[indexPath.row] = row + agreeStates.accept(rows) + break + case .WebView: + print(row.model.detailLink) + webViewTrigger.accept(row.model.detailLink) + break + } + }.drive() + .disposed(by: disposeBag) + + webViewTrigger.asDriverOnErrorJustEmpty() + .compactMap { $0 } + .compactMap { URL(string: $0) } + .drive(with: self) { owner, url in + owner.delegate?.invoke(.agreementWebView(url)) + }.disposed(by: disposeBag) + + let agreeAllBtnStatus = agreeStates + .asDriver() + .map { $0.allSatisfy { row in + row.checkImage.name == DSKitAsset.Image.Component.checkSelect.name + } } + + input.agreeAllBtn + .withLatestFrom(agreeAllBtnStatus) + .withLatestFrom(agreeStates.asDriver()) { buttonStatus, models in + models.map { + var row = $0 + row.checkImage = !buttonStatus ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check + return row + } + } + .drive(agreeStates) + .disposed(by: disposeBag) + + let nextBtnStatus = agreeStates + .asDriver() + .map { $0.filter { row in row.model.isRequired } + .allSatisfy { row in + row.checkImage.name == DSKitAsset.Image.Component.checkSelect.name + } } - .do { agreeStatus.accept($0) } - let marketingStatus = input.marketingServiceAgreeBtn - .withLatestFrom(agreeStatus.asDriver()) { _, status in - var mutable = status - mutable.marketing.toggle() - return mutable + input.nextBtn + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(agreeStates.asDriver()) + .filter { $0 + .filter { row in + row.model.isRequired + } + .allSatisfy { row in + row.checkImage.name == DSKitAsset.Image.Component.checkSelect.name + } } - .do { agreeStatus.accept($0) } - - let agreeAllRowImage = agreeStatus.asDriver() - .map { $0.total } - .map { $0 ? DSKitAsset.Image.Component.checkCirSelect : DSKitAsset.Image.Component.checkCir } - .asDriver(onErrorJustReturn: DSKitAsset.Image.Component.checkCir) - - let tosAgreeRowImage = agreeStatus.asDriver() - .map { $0.tos } - .map { $0 ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check } - .asDriver(onErrorJustReturn: DSKitAsset.Image.Component.check) - - let privacyAgreeRowImage = agreeStatus.asDriver() - .map { $0.privacy } - .map { $0 ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check } - .asDriver(onErrorJustReturn: DSKitAsset.Image.Component.check) - - let locationServiceAgreeRowImage = agreeStatus.asDriver() - .map { $0.location } - .map { $0 ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check } - .asDriver(onErrorJustReturn: DSKitAsset.Image.Component.check) - - let marketingServiceRowImage = agreeStatus.asDriver() - .map { $0.marketing } - .map { $0 ? DSKitAsset.Image.Component.checkSelect : DSKitAsset.Image.Component.check } - .asDriver(onErrorJustReturn: DSKitAsset.Image.Component.check) - - let nextBtnStatus = agreeStatus.asDriver() - .debug("agreeStatus") - .map { $0.isValid } - - let nextButtonTap = input.nextBtn - .do(onNext: { [weak self] in - self?.delegate?.policyNextButtonTap() + .map { $0.map { + let key = $0.model.name + let value = $0.checkImage.name == DSKitAsset.Image.Component.checkSelect.name ? true : false + return (key, value) + } } + .map { Dictionary(uniqueKeysWithValues: $0) } + .withLatestFrom(userinfo) { agreements, userinfo in + var mutableUserInfo = userinfo + mutableUserInfo.userAgreements = agreements + return mutableUserInfo + } + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtPolicy) }) - - // TODO: Disposable로 만들어서 VC로 넘겨야할지 여기서 Disposed해야할지 아니면 - // 더 좋은 방법이 있을지 고민 - let disposeble = Driver.merge(totalStatus, tosStatus, privacyStatus, locationStatus, marketingStatus).drive() + .disposed(by: disposeBag) return Output( - agreeAllRowImage: agreeAllRowImage, - tosAgreeRowImage: tosAgreeRowImage, - privacyAgreeRowImage: privacyAgreeRowImage, - locationServiceAgreeRowImage: locationServiceAgreeRowImage, - marketingServiceRowImage: marketingServiceRowImage, - nextBtnStatus: nextBtnStatus, - nextButtonTap: nextButtonTap, - disposeble: disposeble + agreeAllBtnStatus: agreeAllBtnStatus, + cellViewModels: agreeStates.asDriver(), + nextBtnStatus: nextBtnStatus ) } } diff --git a/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerView.swift b/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerView.swift new file mode 100644 index 00000000..b927e568 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerView.swift @@ -0,0 +1,121 @@ +// +// PreferGenderPickerView.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// +import UIKit + +import DSKit +import RxSwift +import SignUpInterface + +final class PreferGenderPickerView: TFBaseView { + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var genderPickerView = TFButtonPickerView( + title: "선호 성별을 알려주세요.", targetString: "선호 성별", + options: GenderMapper.options + ) + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.text = "마이페이지에서 변경가능합니다." + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 1 + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + container.addSubviews( + genderPickerView, + infoImageView, descLabel, + nextBtn + ) + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + genderPickerView.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview().inset(0) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(genderPickerView.snp.leading).offset(30) + $0.width.height.equalTo(16) + $0.top.equalTo(genderPickerView.snp.bottom).offset(10) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.top.equalTo(infoImageView) + $0.trailing.equalToSuperview().inset(38) + } + nextBtn.snp.makeConstraints { + $0.top.equalTo(descLabel.snp.bottom).offset(30) + $0.trailing.equalTo(descLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} + +extension Reactive where Base: PreferGenderPickerView { + var selectedGender: Binder { + Binder(base.self) { view, gender in + view.genderPickerView.handleSelectedState(GenderMapper.toOption(gender)) + } + } +} + +struct GenderMapper { + static func toOption(_ gender: Gender) -> TFButtonPickerView.Option { + switch gender { + case .female: + return .init(key: 0, value: "여자") + case .male: + return .init(key: 1, value: "남자") + case .both: + return .init(key: 2, value: "모두") + } + } + + static func toGender(_ option: TFButtonPickerView.Option) -> Gender { + switch option.key { + case 0: return .female + case 1: return .male + default: return .both + } + } + + static var options: [String] { + return ["여자", "남자", "모두"] + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PreferGenderPickerViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return PreferGenderPickerView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerViewController.swift new file mode 100644 index 00000000..316dd50c --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerViewController.swift @@ -0,0 +1,59 @@ +// +// PreferGenderPickerViewController.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit + +import DSKit +import SignUpInterface + +import RxSwift +import RxCocoa + +final class PreferGenderPickerViewController: TFBaseViewController { + private let mainView = PreferGenderPickerView() + private let viewModel: PreferGenderPickerViewModel + + init(viewModel: PreferGenderPickerViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + let genderTap = self.mainView.genderPickerView + .rx.selectedOption + .asDriver() + .compactMap { $0 } + .compactMap { GenderMapper.toGender($0) } + + let input = PreferGenderPickerViewModel.Input( + genderTap: genderTap, + nextBtnTap: self.mainView.nextBtn.rx.tap.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.isNextBtnEnabled + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + + output.initialGender + .debug("initialGender") + .drive(mainView.rx.selectedGender) + .disposed(by: disposeBag) + } +} + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerViewModel.swift new file mode 100644 index 00000000..f4daf5c0 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/PreferGenderPick/PreferGenderPickerViewModel.swift @@ -0,0 +1,82 @@ +// +// PreferGenderPickerViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import Foundation + +import Core +import SignUpInterface + +import RxSwift +import RxCocoa + +final class PreferGenderPickerViewModel: ViewModelType { + weak var delegate: SignUpCoordinatingActionDelegate? + private let userInfoUseCase: UserInfoUseCaseInterface + + struct Input { + var genderTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var isNextBtnEnabled: Driver + var initialGender: Driver + } + + private var disposeBag = DisposeBag() + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + deinit { + print("deinit: PreferGenderPickerViewModel") + } + + func transform(input: Input) -> Output { + + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + .debug("fetched UserInfo") + + let initialGender = userinfo.map { $0.preferGender } + .compactMap { $0 } + .debug("fetched preferGender") + + let selectedGender = input.genderTap + .debug("tapped Gender") + + let nextBtnisEnabled = selectedGender.map { _ in true } + + input.nextBtnTap + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(selectedGender) + .debug("toSave preferGender") + .withLatestFrom(userinfo) { preferGender, info in + var mutable = info + mutable.preferGender = preferGender + print("mutable - preferGender: \(preferGender)") + return mutable + } + .drive(with: self) { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtPreferGender) + }.disposed(by: disposeBag) + + return Output( + isNextBtnEnabled: nextBtnisEnabled, + initialGender: initialGender + ) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionPickerView.swift b/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionPickerView.swift new file mode 100644 index 00000000..b1a24273 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionPickerView.swift @@ -0,0 +1,111 @@ +// +// ReligionPickerView.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import UIKit + +import DSKit + +final class ReligionPickerView: TFBaseView { + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "종교를 알려주세요" + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH1B + $0.asColor(targetString: "종교", color: DSKitAsset.Color.neutral50.color) + } + + lazy var ReligionPickerView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout() + .then { + $0.itemSize = CGSize(width: 90, height: 60) + } + ).then { + $0.register(cellType: ReligionPickerCell.self) + $0.backgroundColor = DSKitAsset.Color.neutral700.color + $0.showsHorizontalScrollIndicator = false + $0.showsVerticalScrollIndicator = false + $0.isScrollEnabled = false + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.text = "마이페이지에서 변경가능합니다." + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 1 + } + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + container.addSubviews( + titleLabel, + ReligionPickerView, + infoImageView, descLabel, + nextBtn + ) + + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview().inset(30) + } + + ReligionPickerView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(35) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(140) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(titleLabel.snp.leading) + $0.width.height.equalTo(16) + $0.top.equalTo(ReligionPickerView.snp.bottom).offset(16) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.top.equalTo(ReligionPickerView.snp.bottom).offset(16) + $0.trailing.equalToSuperview().inset(38) + } + nextBtn.snp.makeConstraints { + $0.top.equalTo(descLabel.snp.bottom).offset(30) + $0.trailing.equalTo(descLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReligionPickerViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return ReligionPickerView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionPickerViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionPickerViewController.swift new file mode 100644 index 00000000..f278c129 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionPickerViewController.swift @@ -0,0 +1,59 @@ +// +// ReligionPickerViewController.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import UIKit + +import DSKit + +import RxSwift +import RxCocoa + +final class ReligionPickerViewController: TFBaseViewController { + typealias VMType = ReligionPickerViewModel + private(set) var mainView = ReligionPickerView() + + private let viewModel: VMType + + init(viewModel: VMType) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + + let nextBtnTap = mainView.nextBtn.rx.tap.asDriver() + + let input = VMType.Input( + chipTap: mainView.ReligionPickerView.rx.itemSelected.asDriver(), + nextBtnTap: nextBtnTap + ) + + let output = viewModel.transform(input: input) + + output.chips + .debug("chips") + .drive(mainView.ReligionPickerView.rx.items(cellType: ReligionPickerCell.self)) { index, item, cell in + cell.bind(item.0) + cell.updateCell(item.1) + } + .disposed(by: disposeBag) + + output.isNextBtnEnabled + .drive(with: self) { owner, status in + owner.mainView.nextBtn.updateColors(status: status) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionViewModel.swift new file mode 100644 index 00000000..7728059a --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/Religion/ReligionViewModel.swift @@ -0,0 +1,127 @@ +// +// ReligionViewModel.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/25. +// + +import Foundation + +import Core + +import RxSwift +import RxCocoa +import Domain +import SignUpInterface + +final class ReligionPickerViewModel: ViewModelType { + weak var delegate: SignUpCoordinatingActionDelegate? + private let userInfoUseCase: UserInfoUseCaseInterface + + struct Input { + var chipTap: Driver + var nextBtnTap: Driver + } + + struct Output { + var chips: Driver<[(Religion, Bool)]> + var isNextBtnEnabled: Driver + } + + private var disposeBag = DisposeBag() + + init(userInfoUseCase: UserInfoUseCaseInterface) { + self.userInfoUseCase = userInfoUseCase + } + + func transform(input: Input) -> Output { + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .catchAndReturn(UserInfo(phoneNumber: "")) + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let initialReligion = userinfo.compactMap { $0.religion } + + let chips = BehaviorRelay<[(Religion, Bool)]>(value: Religion.allCases.map { ($0, false) }) + + initialReligion + .withLatestFrom(chips.asDriver()) { initial, chips in + var mutable = chips + guard let index = chips.firstIndex(where: { (item, _) in + item == initial + }) else { return mutable} + mutable[index] = (initial, true) + return mutable + } + .drive(chips) + .disposed(by: disposeBag) + + input.chipTap + .withLatestFrom(chips.asDriver()) { indexPath, array in + var mutable = array.map { ($0.0, false) } + mutable[indexPath.row] = (mutable[indexPath.row].0, true) + return mutable + } + .debug("tap chips") + .drive(chips) + .disposed(by: disposeBag) + + let selectedChip = chips + .asDriver() + .map { + $0.first { (_, isSelected) in + isSelected + }.map { $0.0 } + }.compactMap { $0 } + + let isNextBtnEnabled = chips + .asDriver() + .map { $0.map { $0.1 } } + .map { + $0.filter { $0 == true }.count == 1 + } + + input.nextBtnTap + .withLatestFrom(isNextBtnEnabled) + .filter { $0 } + .withLatestFrom(selectedChip) + .withLatestFrom(userinfo) { item, userinfo in + var mutable = userinfo + mutable.religion = item + return mutable + } + .drive(with: self, onNext: { owner, userinfo in + owner.userInfoUseCase.updateUserInfo(userInfo: userinfo) + owner.delegate?.invoke(.nextAtReligion) + }) + .disposed(by: disposeBag) + + return Output( + chips: chips.asDriver(), + isNextBtnEnabled: isNextBtnEnabled + ) + } +} + +extension Religion: CaseIterable { + public static var allCases: [Religion] = [ + .none, .christian, .buddhism, + .catholic, .wonBuddhism, .other + ] + + public var label: String { + switch self { + case .none: return "무교" + case .christian: return "기독교" + case .buddhism: return "불교" + case .catholic: return "천주교" + case .wonBuddhism: return "원불교" + case .other: return "기타" + } + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteView.swift b/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteView.swift new file mode 100644 index 00000000..ac03b40a --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteView.swift @@ -0,0 +1,128 @@ +// +// SignUpCompleteView.swift +// SignUpInterface +// +// Created by Kanghos on 5/28/24. +// + +import UIKit + +import DSKit + +final class SignUpCompleteView: TFBaseView { + lazy var containerView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "환영해요! 💫\n모든 준비가 끝났어요" + $0.textColor = DSKitAsset.Color.neutral50.color + $0.font = .thtH2B + $0.numberOfLines = 0 + $0.textAlignment = .center + } + + private lazy var conicGradient: CAGradientLayer = { + let gradient = CAGradientLayer() + gradient.type = .conic + gradient.colors = [ + DSKitAsset.Color.primary500.color.cgColor, + DSKitAsset.Color.thtOrange400.color.cgColor + ] + gradient.locations = [0] + + // startPoint: 원의 중심, endPoint: 첫 번째 색상과 마지막 색상이 결합되는 지점 + // (0,0)우측하단, (1,1)은 (0,0)에서 한바퀴 돌은 지점 + gradient.startPoint = CGPoint(x: 0.5, y: 0.5) + gradient.endPoint = CGPoint(x: 0.5, y: 1) + return gradient + }() + + private lazy var gradientView = UIView().then { + $0.backgroundColor = .green + $0.layer.addSublayer(conicGradient) + $0.layer.cornerRadius = 32 + $0.clipsToBounds = true + } + + lazy var descLabel = UILabel().then { + $0.text = "폴링에 한 번 빠져 보시겠어요" + $0.font = .thtSubTitle1R + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .center + $0.numberOfLines = 2 + } + + lazy var imageView = UIImageView().then { + $0.image = DSKitAsset.Image.Test.test1.image + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + $0.layer.cornerRadius = 20 + $0.layer.borderColor = DSKitAsset.Color.neutral700.color.cgColor + $0.layer.borderWidth = 10 + } + + lazy var nextBtn = CTAButton(btnTitle: "네, 좋아요", initialStatus: true) + + override func makeUI() { + addSubview(containerView) + containerView.addSubviews( + titleLabel, + descLabel, + gradientView, + imageView, + nextBtn + ) + + containerView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(76) + $0.leading.trailing.equalToSuperview().inset(38) + } + + descLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(38) + } + gradientView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(250) + } + + imageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(235) + } + + nextBtn.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().offset(-60) + $0.height.equalTo(54) + } + + } + + override func layoutSubviews() { + super.layoutSubviews() + conicGradient.frame = gradientView.bounds + } +} +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SignUpCompleteViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = SignUpCompleteView() + return component + } + .previewLayout(.sizeThatFits) + } +} +#endif + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteViewController.swift new file mode 100644 index 00000000..10afe77f --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteViewController.swift @@ -0,0 +1,55 @@ +// +// SignUpCompleteViewController.swift +// SignUpInterface +// +// Created by Kanghos on 5/28/24. +// + +import UIKit +import DSKit + +final class SignUpCompleteViewController: TFBaseViewController { + typealias ViewModel = SignUpCompleteViewModel + + private let mainView = SignUpCompleteView() + private let viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + let input = ViewModel.Input( + nextBtnTap: mainView.nextBtn.rx.tap.asDriver() + ) + + let output = viewModel.transform(input: input) + + output.toast + .emit(with: self) { owner, message in + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + + owner.present(alert, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + owner.dismiss(animated: true) + } + } + .disposed(by: disposeBag) + + output.profileImage + .compactMap { $0 } + .drive(with: self, onNext: { owner, data in + owner.mainView.imageView.image = UIImage(data: data) + }) + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteViewModel.swift new file mode 100644 index 00000000..1a9f3fee --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/SignUpComplete/SignUpCompleteViewModel.swift @@ -0,0 +1,130 @@ +// +// SignUpCompleteViewModel.swift +// SignUpInterface +// +// Created by Kanghos on 5/28/24. +// + +import Foundation + +import Core + +import RxSwift +import RxCocoa + +import SignUpInterface + +final class SignUpCompleteViewModel: ViewModelType { + private let useCase: SignUpUseCaseInterface + private let userInfoUseCase: UserInfoUseCaseInterface + private let contacts: [ContactType] + weak var delegate: SignUpCoordinatingActionDelegate? + private let disposeBag = DisposeBag() + + init(useCase: SignUpUseCaseInterface, userInfoUseCase: UserInfoUseCaseInterface, contacts: [ContactType]) { + self.useCase = useCase + self.userInfoUseCase = userInfoUseCase + self.contacts = contacts + } + + struct Input { + let nextBtnTap: Driver + } + + struct Output { + let loadTrigger: Driver + let toast: Signal + let profileImage: Driver + } + + func transform(input: Input) -> Output { + let toast = PublishSubject() + let loadTrigger = PublishRelay() + + let contacts = Driver.just(self.contacts) + + let userinfo = Driver.just(()) + .asObservable() + .withUnretained(self) + .flatMap { owner, _ in + owner.userInfoUseCase.fetchUserInfo() + .asObservable() + } + .asDriverOnErrorJustEmpty() + + let photos = userinfo.map { $0.photos } + + let phoneNum = userinfo.map { $0.phoneNumber } + + let imagesData = photos + .withLatestFrom(phoneNum) { (key: $1, filenames: $0) } + .asObservable() + .withUnretained(self) + .flatMap { owner, components in + owner.userInfoUseCase.fetchUserPhotos(key: components.key, fileNames: components.filenames) + .catch { error in + toast.onNext("사진을 불러오는데 실패했습니다.") + return .just([]) + } + .asObservable() + }.asDriverOnErrorJustEmpty() + + // 이미지 업로드 + // SignUpRequest + + let imageUrls = input.nextBtnTap + .throttle(.milliseconds(500), latest: false) + .withLatestFrom(imagesData) + .asObservable() + .withUnretained(self) + .flatMapLatest { owner, data in + owner.useCase.uploadImage(data: data) + .catch { error in + toast.onNext("이미지 업로드에 실패했습니다.") + return .just([]) + } + } + .asDriverOnErrorJustEmpty() + + let signUpRequest = Driver.combineLatest(imageUrls, userinfo, contacts) { urls, userinfo, contacts in + var mutable = userinfo + mutable.photos = urls + return mutable.toRequest(contacts: contacts) + } + .flatMap { requestOrNil -> Driver in + guard let request = requestOrNil else { + toast.onNext("입력하신 회원 정보가 올바르지 않습니다.") + return Driver.empty() + } + return Driver.just(request) + } + .debug("signUpRequest") + + let signUpResult = signUpRequest + .asObservable() + .withUnretained(self) + .flatMap { owner, request in + owner.useCase.signUp(request: request) + .asObservable() + .catch { error in + toast.onNext("회원가입에 실패했습니다.") + return .empty() + } + } + .asDriverOnErrorJustEmpty() + + signUpResult + .withLatestFrom(phoneNum) + .drive(with: self) { owner, phoneNum in + owner.userInfoUseCase.savePhoneNumber(phoneNum) + owner.delegate?.invoke(.nextAtSignUpComplete) + } + .disposed(by: disposeBag) + + return Output( + loadTrigger: loadTrigger.asDriverOnErrorJustEmpty(), + toast: toast.asSignal(onErrorSignalWith: .empty()), + profileImage: imagesData.map { $0.first } + ) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/SignUpRootViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/SignUpRootViewModel.swift deleted file mode 100644 index 065e0809..00000000 --- a/Projects/Features/SignUp/Src/SignUpRoot/SignUpRootViewModel.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// SignUpViewModel.swift -// Falling -// -// Created by Hoo's MacBookPro on 2023/07/22. -// - -import Foundation - -import RxSwift -import RxCocoa - -import Core - -// FIXME: Refactor Need -protocol SignUpRootDelegate: AnyObject { - func toPhoneButtonTap() -} - -final class SignUpRootViewModel: ViewModelType { - struct Input { - let phoneBtn: Driver - let kakaoBtn: Driver - let googleBtn: Driver - let naverBtn: Driver - } - - struct Output { - - } - - weak var delegate: SignUpRootDelegate? - - var disposeBag: DisposeBag = DisposeBag() - - func transform(input: Input) -> Output { - input.phoneBtn - .drive(with: self, onNext: { owner, _ in - owner.delegate?.toPhoneButtonTap() - }) - .disposed(by: disposeBag) - - input.kakaoBtn - .drive(onNext: { [weak self] in - guard let self else { return } - print("kakao button") - }) - .disposed(by: disposeBag) - - input.naverBtn - .drive(onNext: { [weak self] in - guard let self else { return } - print("naver button") - }) - .disposed(by: disposeBag) - - input.googleBtn - .drive(onNext: { [weak self] in - guard let self else { return } - print("google button") - }) - .disposed(by: disposeBag) - - return Output() - } -} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactView.swift b/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactView.swift new file mode 100644 index 00000000..53683613 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactView.swift @@ -0,0 +1,86 @@ +// +// UserContactView.swift +// SignUpInterface +// +// Created by kangho lee on 5/2/24. +// + +import UIKit + +import DSKit + +final class UserContactView: TFBaseView { + lazy var containerView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = "혹시 폴링에서\n아는 사람을 만날까봐\n걱정되시나요?" + $0.textColor = DSKitAsset.Color.neutral50.color + $0.font = .thtH2B + $0.numberOfLines = 0 + } + + lazy var descLabel = UILabel().then { + $0.text = "폴링에서 아는 사람을 마주치고 싶지 않다면\n해당 기능을 켜주세요." + $0.font = .thtSubTitle1R + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 2 + } + + lazy var blockBtn = CTAButton(btnTitle: "아는 사람 만나지 않기", initialStatus: true) + lazy var layterBtn = CTAButton(btnTitle: "나중에 하기", initialStatus: false) + + override func makeUI() { + addSubview(containerView) + containerView.addSubviews( + titleLabel, + descLabel, + blockBtn, + layterBtn + ) + + containerView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(76) + $0.leading.equalToSuperview().inset(38) + } + + descLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(38) + } + + blockBtn.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalTo(layterBtn.snp.top).offset(-16) + $0.height.equalTo(54) + } + + layterBtn.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().offset(-60) + $0.height.equalTo(54) + } + } +} +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct InputViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = UserContactView() + return component + } + .previewLayout(.sizeThatFits) + } +} +#endif + diff --git a/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactViewController.swift b/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactViewController.swift new file mode 100644 index 00000000..4a0f9668 --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactViewController.swift @@ -0,0 +1,54 @@ +// +// UserContactViewController.swift +// SignUpInterface +// +// Created by kangho lee on 5/2/24. +// + +import UIKit +import DSKit + +final class UserContactViewController: TFBaseViewController { + typealias ViewModel = UserContactViewModel + typealias Action = ViewModel.Action + + private let mainView = UserContactView() + private let viewModel: UserContactViewModel + + init(viewModel: UserContactViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func bindViewModel() { + let block = mainView.blockBtn.rx.tap + .asDriver() + .map { Action.block } + + let skip = mainView.layterBtn.rx.tap + .asDriver() + .map { Action.skip } + let actiontrigger = Driver.merge(block, skip) + let input = UserContactViewModel.Input(actionTrigger: actiontrigger) + let output = viewModel.transform(input: input) + + output.toast + .emit(with: self) { owner, message in + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + + owner.present(alert, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + owner.dismiss(animated: true) + } + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactViewModel.swift b/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactViewModel.swift new file mode 100644 index 00000000..5d3b1a2d --- /dev/null +++ b/Projects/Features/SignUp/Src/SignUpRoot/UserContact/UserContactViewModel.swift @@ -0,0 +1,90 @@ +// +// UserContactViewModel.swift +// SignUpInterface +// +// Created by kangho lee on 5/2/24. +// + +import Foundation + +import Core + +import RxSwift +import RxCocoa + +import SignUpInterface + +final class UserContactViewModel: ViewModelType { + private let useCase: SignUpUseCaseInterface + weak var delegate: SignUpCoordinatingActionDelegate? + private let disposeBag = DisposeBag() + + enum Action { + case block + case skip + } + + struct Input { + let actionTrigger: Driver + } + + struct Output { + let toast: Signal + } + + init(useCase: SignUpUseCaseInterface) { + self.useCase = useCase + } + + func transform(input: Input) -> Output { + let toast = PublishSubject() + let nextTrigger = PublishSubject() + let blockTrigger = PublishSubject() + let fetchedContacts = BehaviorRelay<[ContactType]>(value: []) + + input.actionTrigger + .drive(onNext: { action in + switch action { + case .block: + blockTrigger.onNext(()) + case .skip: + nextTrigger.onNext(()) + } + }) + .disposed(by: disposeBag) + + blockTrigger + .observe(on: ConcurrentDispatchQueueScheduler(qos: .background)) + .flatMapLatest { [unowned self] _ in + self.useCase.block() + .flatMap { contacts in + fetchedContacts.accept(contacts) + toast.onNext("\(contacts.count)개의 연락처를 차단했습니다.") + return .just(true) + } + .catch { error in + toast.onNext("친구 목록을 불러오는데 실패했습니다. 다시 시도해주세요.") + return .just(false) + } + } + .asDriver(onErrorJustReturn: false) + .filter { $0 } + .map { _ in } + .delay(.seconds(2)) + .drive(nextTrigger) + .disposed(by: disposeBag) + // TODO: Block 성공하면 toast 띄우고, 2초 뒤 next, 실패하면 toast 띄우고, next 안함 + + nextTrigger + .withLatestFrom(fetchedContacts) + .asDriverOnErrorJustEmpty() + .drive(with: self) { owner, contacts in + owner.delegate?.invoke(.nextAtHideFriends(contacts)) + } + .disposed(by: disposeBag) + + return Output( + toast: toast.asSignal(onErrorJustReturn: "") + ) + } +} diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerAdapter.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerAdapter.swift new file mode 100644 index 00000000..d5c892f2 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerAdapter.swift @@ -0,0 +1,51 @@ +// +// PickerAdapter.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit + +import DSKit +import RxSwift +import RxCocoa + +final class PickerViewViewAdapter +: NSObject +, UIPickerViewDataSource +, UIPickerViewDelegate +, RxPickerViewDataSourceType +, SectionedViewDataSourceType { + typealias Element = [[Int]] + + private var items: Element = [] + + func model(at indexPath: IndexPath) throws -> Any { + items[indexPath.section][indexPath.row] + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + items.count + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + items[component].count + } + + func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView { + let label: UILabel = (view as? UILabel) ?? UILabel() + label.text = "\(items[component][row])" + label.textColor = DSKitAsset.Color.primary500.color + label.font = UIFont.thtH2B + label.textAlignment = .center + return label + } + + func pickerView(_ pickerView: UIPickerView, observedEvent: Event) { + Binder(self) { (adapter, items) in + adapter.items = items + pickerView.reloadAllComponents() + }.on(observedEvent) + } +} diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheet.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheet.swift new file mode 100644 index 00000000..bb1290b3 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheet.swift @@ -0,0 +1,60 @@ +// +// PickerButtonSheet.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit + +import Core +import DSKit + +import RxSwift +import RxCocoa + +final class PickerBottomSheet: TFBaseViewController { + + private let mainView = PickerBottomSheetView() + private let viewModel: PickerBottomSheetViewModel + + init(viewModel: PickerBottomSheetViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func viewDidLayoutSubviews() { + self.mainView.drawSelectedLine() + self.navigationController?.isNavigationBarHidden = true + } + + override func bindViewModel() { + let itemSelected = mainView.pickerView.rx.itemSelected.asDriver() + + let confirmTap = mainView.initializeButton.rx.tap.asDriver() +// + let input = PickerBottomSheetViewModel.Input( + selectedItem: itemSelected, + initializeButtonTap: confirmTap + ) +// + let output = viewModel.transform(input: input) + + output.items + .drive(mainView.pickerView.rx.items(adapter: PickerViewViewAdapter())) + .disposed(by: disposeBag) + output.initialDate + .drive(with: self) { owner, components in + owner.mainView.pickerView.selectRow(components.0, inComponent: components.1, animated: false) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheetView.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheetView.swift new file mode 100644 index 00000000..3a99d6f7 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheetView.swift @@ -0,0 +1,88 @@ +// +// PickerBottomSheetView.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit + +import Core +import DSKit + +class PickerBottomSheetView: TFBaseView { + + private(set) lazy var pickerView: UIPickerView = { + let pickerView = UIPickerView() + + return pickerView + }() + + private(set) lazy var buttonHStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 10 + stackView.distribution = .fillEqually + return stackView + }() + + private(set) lazy var initializeButton = CTAButton(btnTitle: "확인", initialStatus: true) + + override func makeUI() { + self.backgroundColor = DSKitAsset.Color.neutral600.color + + addSubviews(pickerView, buttonHStackView) + + [initializeButton].forEach { + buttonHStackView.addArrangedSubview($0) + } + + pickerView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + } + + buttonHStackView.snp.makeConstraints { + $0.top.equalTo(pickerView.snp.bottom).offset(10) + $0.height.equalTo(50) + $0.leading.trailing.equalTo(safeAreaLayoutGuide).inset(30) + $0.bottom.equalTo(self.safeAreaLayoutGuide) + } + + self.clipsToBounds = true + } + + func drawSelectedLine() { + var selectedView = pickerView.subviews[1] + selectedView.backgroundColor = .clear + + let space = 10.0 + let fwidth = pickerView.frame.width - (space * 2) + + let fragmentWidth = fwidth / 3 - 3 + let fragmentY = selectedView.frame.height - 3 + var dx = 0.0 + for _ in 0..<3 { + var topLine = UIView(frame: CGRect(x: dx, y: 0, width: fragmentWidth, height: 3)) + var bottomLine = UIView(frame: CGRect(x: dx, y: fragmentY, width: fragmentWidth, height: 3)) + dx += fragmentWidth + 5 + topLine.backgroundColor = DSKitAsset.Color.neutral500.color + bottomLine.backgroundColor = DSKitAsset.Color.neutral500.color + selectedView.addSubview(topLine) + selectedView.addSubview(bottomLine) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PickerBottomSheetViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return PickerBottomSheetView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheetViewModel.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheetViewModel.swift new file mode 100644 index 00000000..636aecca --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/PickerBottomSheet/PickerBottomSheetViewModel.swift @@ -0,0 +1,133 @@ +// +// PickerBottomSheetViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// +import Foundation + +import SignUpInterface + +import RxSwift +import RxCocoa + +import Core + +class PickerBottomSheetViewModel: ViewModelType { +// private let filterType: Item + private let initialValue: BottomSheetValueType + + weak var listener: BottomSheetListener? + weak var delegate: BottomSheetActionDelegate? + + private var disposeBag = DisposeBag() + + init(initialValue: BottomSheetValueType) { + self.initialValue = initialValue + } + + public struct Input { + let selectedItem: Driver<(row: Int, component: Int)> + let initializeButtonTap: Driver + } + + public struct Output { + let items: Driver<[[Int]]> + let initialDate: Driver<(Int, Int)> + } + + public func transform(input: Input) -> Output { + let thisYear = Calendar.current.component(.year, from: Date()) + let adultYear = thisYear - 21 + let years = Array(((adultYear - 20)...adultYear).reversed()) + let months = Array(1...12) + + let selectedYear = BehaviorSubject(value: adultYear) + let selectedMonth = BehaviorSubject(value: 1) + let selectedDay = BehaviorSubject(value: 1) + + let loadTrigger = Observable.just(Void()) + let calculateDaysTrigger = PublishSubject() // 월마다 일 수가 달라서 만듦. + let outputItems = Observable.merge(loadTrigger, calculateDaysTrigger) + .map { _ -> [[Int]] in + let numberOfDays = Date().daysInMonth(month: try selectedMonth.value(), year: try selectedYear.value()) + return [years, months, Array(1...numberOfDays)] + } + .asDriver(onErrorJustReturn: [[1],[2],[3]]) + + let initialDate = outputItems + .map { _ in } + .asObservable() + .take(1) + .withUnretained(self) + .flatMap { owner, _ in + if case let .date(date) = owner.initialValue { + // 값 동기화 위해서 사용함 + // 하지 않으면, 연/월/일 컴포넌트 중 하나만 움직였을 시, VC값과 VM값 차이가 발생함. + let compoents = Calendar.current.dateComponents([.year, .month, .day], from: date) + selectedYear.onNext(compoents.year ?? adultYear) + selectedMonth.onNext(compoents.month ?? 1) + selectedDay.onNext(compoents.day ?? 1) + return Observable.just(date) + } + return Observable.empty() + } + .asDriverOnErrorJustEmpty() + + let initialComponents = initialDate + .map { date in + // TODO: (Int, Int) tuple 형태로 만들고 reduce하여 array 형태로 전달 + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: date) + let thisYear = Calendar.current.component(.year, from: Date()) + let adultYear = thisYear - 21 // max값 + + let yearIdx = adultYear - calendar.component(.year, from: date) + let monthIdx = calendar.component(.month, from: date) - 1 + let dayIdx = calendar.component(.day, from: date) - 1 + + return [(yearIdx, 0), (monthIdx, 1), (dayIdx, 2)] + } + .flatMap { components in + return Driver.from(components) + } +// + + let selectedDate = input.selectedItem + .debug("selected") + .withLatestFrom(outputItems) { picker, items in + let selectedValue = items[picker.component][picker.row] + if picker.component < 2 { + if picker.component == 0 { + selectedYear.onNext(selectedValue) + } else { + selectedMonth.onNext(selectedValue) + } + calculateDaysTrigger.onNext(()) + } else { + selectedDay.onNext(selectedValue) + } + }.compactMap { + var dateComponent = DateComponents() + dateComponent.day = try? selectedDay.value() + dateComponent.month = try? selectedMonth.value() + dateComponent.year = try? selectedYear.value() + + return Calendar.current.date(from: dateComponent) + } + let outputDate = Driver.merge(initialDate, selectedDate) + + input.initializeButtonTap + .withLatestFrom(outputDate) + .drive(with: self, onNext: { owner, date in + owner.listener?.sendData(item: .date(date: date)) + owner.delegate?.sheetInvoke(.onDismiss) + }) + .disposed(by: disposeBag) + + return Output( + items: outputItems, + initialDate: initialComponents + ) + } +} diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerBottomSheet.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerBottomSheet.swift new file mode 100644 index 00000000..0decb1b3 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerBottomSheet.swift @@ -0,0 +1,67 @@ +// +// SinglePickerBottomSheet.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import Core +import DSKit + +import RxSwift +import RxCocoa + +final class SinglePickerBottomSheet: TFBaseViewController { + + private let mainView = SinglePickerBottomSheetView() + private let viewModel: SinglePickerBottomSheetViewModel + + init(viewModel: SinglePickerBottomSheetViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func viewDidLayoutSubviews() { + self.mainView.drawSelectedLine() + self.navigationController?.isNavigationBarHidden = true + } + + override func bindViewModel() { + let itemSelected = mainView.pickerView.rx.itemSelected.asDriver() + + let confirmTap = mainView.initializeButton.rx.tap.asDriver() +// + let input = SinglePickerBottomSheetViewModel.Input( + selectedItem: itemSelected, + initializeButtonTap: confirmTap + ) +// + let output = viewModel.transform(input: input) + + output.items + .drive(mainView.pickerView.rx.items(adapter: PickerViewViewAdapter())) + .disposed(by: disposeBag) + } +} + +//#if canImport(SwiftUI) && DEBUG +//import SwiftUI +// +//struct PickerViewController_Preview: PreviewProvider { +// static var previews: some View { +// let vm = PickerBottomSheetViewModel(initialValue: .date(date: Date())) +// let vc = PickerBottomSheet(viewModel: vm) +// return vc.showPreview() +// } +//} +//#endif diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerBottomSheetViewModel.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerBottomSheetViewModel.swift new file mode 100644 index 00000000..045801f7 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerBottomSheetViewModel.swift @@ -0,0 +1,65 @@ +// +// SinglePickerBottomSheetViewModel.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import Foundation + +import SignUpInterface + +import RxSwift +import RxCocoa + +import Core + +class SinglePickerBottomSheetViewModel: ViewModelType { +// private let filterType: Item + private let initialValue: BottomSheetValueType + + weak var listener: BottomSheetListener? + weak var delegate: BottomSheetActionDelegate? + + private var disposeBag = DisposeBag() + + init(initialValue: BottomSheetValueType) { + self.initialValue = initialValue + } + + public struct Input { + let selectedItem: Driver<(row: Int, component: Int)> + let initializeButtonTap: Driver + } + + public struct Output { + let items: Driver<[[Int]]> + } + + public func transform(input: Input) -> Output { + let loadTrigger = Observable.just(Void()) + let outputItems = loadTrigger + .map { _ -> [[Int]] in + let heightArray = Array(145...200) + return [heightArray] + } + + let selectedValue = input.selectedItem + .asObservable() + .withLatestFrom(outputItems) { picker, items in + return items[picker.component][picker.row] + }.map { String($0) } + + input.initializeButtonTap + .withLatestFrom(selectedValue.asDriverOnErrorJustEmpty()) + .drive(with: self, onNext: { owner, value in + owner.listener?.sendData(item: .text(text: value)) + owner.delegate?.sheetInvoke(.onDismiss) + }) + .disposed(by: disposeBag) + + return Output( + items: outputItems.asDriverOnErrorJustEmpty() + ) + } +} diff --git a/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerViewController.swift b/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerViewController.swift new file mode 100644 index 00000000..01edaa1c --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/BottomSheet/SinglePickerBottomSheet/SinglePickerViewController.swift @@ -0,0 +1,92 @@ +// +// SinglePickerView.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import Core +import DSKit + +class SinglePickerBottomSheetView: TFBaseView { + + private(set) lazy var pickerView: UIPickerView = { + let pickerView = UIPickerView() + + return pickerView + }() + + private(set) lazy var buttonHStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 10 + stackView.distribution = .fillEqually + return stackView + }() + + private(set) lazy var initializeButton = CTAButton(btnTitle: "확인", initialStatus: true) + + override func makeUI() { + self.backgroundColor = DSKitAsset.Color.neutral600.color + + addSubviews(pickerView, buttonHStackView) + + [initializeButton].forEach { + buttonHStackView.addArrangedSubview($0) + } + + pickerView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + } + + buttonHStackView.snp.makeConstraints { + $0.top.equalTo(pickerView.snp.bottom).offset(10) + $0.height.equalTo(50) + $0.leading.trailing.equalTo(safeAreaLayoutGuide).inset(30) + $0.bottom.equalTo(self.safeAreaLayoutGuide) + } + + self.clipsToBounds = true + } + + func drawSelectedLine() { + var selectedView = pickerView.subviews[1] + selectedView.backgroundColor = .clear + + let space = 10.0 + let fwidth = pickerView.frame.width - (space * 2) + + let fragmentWidth = fwidth / 3 - 3 + let fragmentY = selectedView.frame.height - 3 + var dx = 0.0 + for _ in 0..<3 { + var topLine = UIView( + frame: + CGRect(x: dx, y: 0, width: fragmentWidth, height: 3)) + var bottomLine = UIView( + frame: + CGRect(x: dx, y: fragmentY, width: fragmentWidth, height: 3)) + dx += fragmentWidth + 5 + topLine.backgroundColor = DSKitAsset.Color.neutral500.color + bottomLine.backgroundColor = DSKitAsset.Color.neutral500.color + selectedView.addSubview(topLine) + selectedView.addSubview(bottomLine) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SinglePickerBottomSheetViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + return SinglePickerBottomSheetView() + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SubView/ButtonPickerView+Rx.swift b/Projects/Features/SignUp/Src/SubView/ButtonPickerView+Rx.swift new file mode 100644 index 00000000..65519f04 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/ButtonPickerView+Rx.swift @@ -0,0 +1,43 @@ +// +// ButtonPickerView+Rx.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit + +import RxSwift +import RxCocoa + +extension Reactive where Base: ButtonPickerView { + var selectedOption: ControlProperty { + return base.rx.controlProperty( + editingEvents: .valueChanged, + getter: { base in + base.selectedOption + }, + setter: { base, value in + if base.selectedOption != value { + base.selectedOption = value + } + } + ) + } +} + +extension Reactive where Base: TFButtonPickerView { + var selectedOption: ControlProperty { + return base.rx.controlProperty( + editingEvents: .valueChanged, + getter: { base in + base.selectedOption + }, + setter: { base, value in + if base.selectedOption != value { + base.selectedOption = value + } + } + ) + } +} diff --git a/Projects/Features/SignUp/Src/SubView/ButtonPickerView.swift b/Projects/Features/SignUp/Src/SubView/ButtonPickerView.swift new file mode 100644 index 00000000..6653eb45 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/ButtonPickerView.swift @@ -0,0 +1,129 @@ +// +// ButtonPickerView.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit + +import DSKit + +class ButtonPickerView: UIControl { + + private let title: String + private let targetString: String + private let option1: String + private let option2: String + + var tapAction: (() -> ())? + + var selectedOption: ButtonOption? = nil { + didSet { + if let selectedOption { + changeButtonStatus(option: selectedOption) + sendActions(for: .valueChanged) + } + } + } + + enum ButtonOption { + case left + case right + } + + init(title: String, targetString: String, option1: String, option2: String) { + self.title = title + self.targetString = targetString + self.option1 = option1 + self.option2 = option2 + + super.init(frame: .zero) + + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + lazy var titleLabel: UILabel = UILabel().then { + $0.text = title + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH1B + $0.asColor(targetString: targetString, color: DSKitAsset.Color.neutral50.color) + } + + lazy var buttonHStackView = UIStackView().then { + $0.axis = .horizontal + $0.addArrangedSubviews([option1Btn, option2Btn]) + $0.distribution = .fillEqually + $0.alignment = .fill + $0.spacing = 10 + } + + lazy var option1Btn = CTAButton(btnTitle: option1, initialStatus: false) + + lazy var option2Btn = CTAButton(btnTitle: option2, initialStatus: false) + + func makeUI() { + + addSubviews( + titleLabel, + buttonHStackView + ) + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(frame.height * 0.09) + $0.leading.trailing.equalToSuperview().inset(38) + } + + buttonHStackView.snp.makeConstraints { + $0.leading.trailing.equalTo(titleLabel) + $0.top.equalTo(titleLabel.snp.bottom).offset(30) + $0.height.equalTo(50) + $0.bottom.equalToSuperview().offset(-10) + } + + option1Btn.addAction(UIAction { [weak self] _ in + self?.selectedOption = .left + }, for: .touchUpInside) + + option2Btn.addAction(UIAction { [weak self] _ in + self?.selectedOption = .right + }, for: .touchUpInside) + } + + func handleSelectedState(_ option: ButtonOption) { + self.selectedOption = option + } + + func changeButtonStatus(option: ButtonOption) { + switch option { + case .left: + option1Btn.updateColors(status: true) + option2Btn.updateColors(status: false) + case .right: + option1Btn.updateColors(status: false) + option2Btn.updateColors(status: true) + } + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI +import DSKit + +struct ButtonPickerViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let text = "성별, 생일을 입력해주세요" + let target = "성별, 생일" + + return ButtonPickerView(title: text, targetString: target, option1: "여자", option2: "남자") + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SubView/CTAButton.swift b/Projects/Features/SignUp/Src/SubView/CTAButton.swift index cf0873e8..dd11b16d 100644 --- a/Projects/Features/SignUp/Src/SubView/CTAButton.swift +++ b/Projects/Features/SignUp/Src/SubView/CTAButton.swift @@ -9,7 +9,7 @@ import UIKit import DSKit -final class CTAButton: UIButton { +class CTAButton: UIButton { init(btnTitle: String, initialStatus: Bool) { super.init(frame: .zero) titleLabel?.font = .thtH5B diff --git a/Projects/Features/SignUp/Src/SubView/Cell/InputTagCollectionViewCell.swift b/Projects/Features/SignUp/Src/SubView/Cell/InputTagCollectionViewCell.swift new file mode 100644 index 00000000..ade4eece --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/Cell/InputTagCollectionViewCell.swift @@ -0,0 +1,121 @@ +// +// InputTagChip.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/24. +// + +import UIKit + +import DSKit +import Domain + +// Suggest Selectable tag chip confirmed collectionView cell +// it has to status selected and non-selected +public final class InputTagCollectionViewCell: TFBaseCollectionViewCell { + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 5 + stackView.alignment = .center + stackView.distribution = .fill + return stackView + }() + + private lazy var emojiView: UILabel = { + let label = UILabel() + label.font = UIFont.thtSubTitle1R + label.text = "" + label.numberOfLines = 1 + return label + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.thtSubTitle1R + label.textColor = DSKitAsset.Color.neutral50.color + label.text = "칩 텍스트" + label.textAlignment = .left + label.numberOfLines = 1 + return label + }() + + public override func makeUI() { + contentView.addSubview(stackView) + + stackView.addArrangedSubviews([emojiView, titleLabel]) + stackView.arrangedSubviews.forEach { subViews in + subViews.snp.makeConstraints { + $0.height.equalTo(40) + } + } + stackView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview().offset(-15) + } + + updateStatus(isSelected: false) + + contentView.layer.masksToBounds = true + contentView.layer.borderColor = DSKitAsset.Color.neutral300.color.cgColor + } + + public override func layoutSubviews() { + super.layoutSubviews() + setUpLayer() + } + + private func setUpLayer() { + contentView.layer.cornerRadius = contentView.frame.height / 2 + } + + public func bind(_ viewModel: InputTagItemViewModel) { + self.titleLabel.text = viewModel.emojiType.name + self.emojiView.text = viewModel.emoji + updateStatus(isSelected: viewModel.isSelected) + } + + public func updateStatus(isSelected: Bool) { + contentView.backgroundColor = isSelected + ? DSKitAsset.Color.primary500.color + : DSKitAsset.Color.neutral700.color + + titleLabel.textColor = isSelected + ? DSKitAsset.Color.neutral700.color + : DSKitAsset.Color.neutral50.color + + contentView.layer.borderWidth = isSelected ? 0 : 1 + } +} + +public struct InputTagItemViewModel { + public let emojiType: EmojiType + public var isSelected: Bool + + public var emoji: String { + return emojiType.emojiCode.unicodeToEmoji() + } + + public init(item: EmojiType, isSelected: Bool) { + self.emojiType = item + self.isSelected = isSelected + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct InputTagCellViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = InputTagCollectionViewCell() + component.bind(.init(item: .init(idx: 1, name: "샘플", emojiCode: "U+1F457"), isSelected: false)) + return component + } + .previewLayout(.sizeThatFits) + } +} +#endif + diff --git a/Projects/Features/SignUp/Src/SubView/Cell/ReligionPickerCell.swift b/Projects/Features/SignUp/Src/SubView/Cell/ReligionPickerCell.swift new file mode 100644 index 00000000..263b5e16 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/Cell/ReligionPickerCell.swift @@ -0,0 +1,57 @@ +// +// ReligionPickerCell.swift +// SignUp +// +// Created by Kanghos on 2024/04/25. +// + +import UIKit + +import DSKit +import SignUpInterface + +final class ReligionPickerCell: TFBaseCollectionViewCell { + + lazy var religionLabel: UILabel = UILabel().then { + $0.font = .thtH4Sb + $0.backgroundColor = .clear + $0.textAlignment = .center + $0.textColor = DSKitAsset.Color.neutral900.color + } + + override func makeUI() { + contentView.addSubview(religionLabel) + contentView.layer.masksToBounds = true + contentView.layer.cornerRadius = 10 + + religionLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(15) + } + } + + func bind(_ model: Religion) { + let text: String + + switch model { + case .christian: + text = "기독교" + case .catholic: + text = "천주교" + case .buddhism: + text = "불교" + case .wonBuddhism: + text = "원불교" + case .none: + text = "무교" + case .other: + text = "기타" + } + religionLabel.text = text + } + + func updateCell(_ isSelected: Bool) { + contentView.backgroundColor = isSelected + ? DSKitAsset.Color.primary500.color + : DSKitAsset.Color.disabled.color + } +} diff --git a/Projects/Features/SignUp/Src/SubView/DatePickerView.swift b/Projects/Features/SignUp/Src/SubView/DatePickerView.swift new file mode 100644 index 00000000..932665ad --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/DatePickerView.swift @@ -0,0 +1,69 @@ +//// +//// DatePickerView.swift +//// SignUp +//// +//// Created by Kanghos on 2024/04/16. +//// +// +//import UIKit +// +//import DSKit +// +//final class DatePickerView: TFBaseView { +// +// lazy var pickerView = UIPickerView().then { +// +// } +// +// lazy var infoImageView: UIImageView = UIImageView().then { +// $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) +// $0.tintColor = DSKitAsset.Color.neutral400.color +// } +// +// lazy var descLabel: UILabel = UILabel().then { +// $0.text = "폴링에서 활동할 자유로운 호칭을 설정해주세요" +// $0.font = .thtCaption1M +// $0.textColor = DSKitAsset.Color.neutral400.color +// $0.textAlignment = .left +// $0.numberOfLines = 1 +// } +// +// override func makeUI() { +// addSubviews( +// pickerView, +// infoImageView, descLabel +// ) +// +// pickerView.snp.makeConstraints { +// $0.top.equalToSuperview().offset(14) +// $0.leading.trailing.equalToSuperview() +// $0.height.equalTo(60) +// } +// +// infoImageView.snp.makeConstraints { +// $0.leading.equalTo(pickerView.snp.leading) +// $0.width.height.equalTo(16) +// $0.top.equalTo(pickerView.snp.bottom).offset(16) +// } +// +// descLabel.snp.makeConstraints { +// $0.leading.equalTo(infoImageView.snp.trailing).offset(6) +// $0.top.equalTo(pickerView.snp.bottom).offset(16) +// $0.trailing.equalToSuperview().inset(38) +// } +// } +//} +// +//#if canImport(SwiftUI) && DEBUG +//import SwiftUI +// +//struct DatePickerViewPreview: PreviewProvider { +// +// static var previews: some View { +// UIViewPreview { +// return DatePickerView() +// } +// .previewLayout(.sizeThatFits) +// } +//} +//#endif diff --git a/Projects/Features/SignUp/Src/SubView/ResizableTextView.swift b/Projects/Features/SignUp/Src/SubView/ResizableTextView.swift new file mode 100644 index 00000000..73685588 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/ResizableTextView.swift @@ -0,0 +1,20 @@ +// +// ResizableTextView.swift +// SignUp +// +// Created by kangho lee on 4/27/24. +// + +import UIKit + +public class ResizableTextView: UITextView { + let minimumHeight: CGFloat = 40 + let maximumHeight: CGFloat = 120 + + public override var intrinsicContentSize: CGSize { + var newSize = super.intrinsicContentSize + newSize.height = min(maximumHeight, max(minimumHeight, newSize.height)) + self.isScrollEnabled = newSize.height == maximumHeight + return newSize + } +} diff --git a/Projects/Features/SignUp/Src/SubView/ServiceAgreementRowView.swift b/Projects/Features/SignUp/Src/SubView/ServiceAgreementRowView.swift index 1d31bce6..31428dff 100644 --- a/Projects/Features/SignUp/Src/SubView/ServiceAgreementRowView.swift +++ b/Projects/Features/SignUp/Src/SubView/ServiceAgreementRowView.swift @@ -8,104 +8,122 @@ import UIKit import DSKit +import SignUpInterface -enum AgreementType { - case termsOfServie - case privacyPolicy - case locationService - case marketingService - - var labelTitle: String { - switch self { - case .termsOfServie: - return "(필수) 이용 약관 동의" - case .privacyPolicy: - return "(필수) 개인 정보 수집 및 이용 동의" - case .locationService: - return "(필수) 위치 기반 서비스 약관 동의" - case .marketingService: - return "(선택) 마케팅 정보 수신 동의" - } - } +final class ServiceAgreementRowView: UITableViewCell { - var isConnectWebView: Bool { - switch self { - case .locationService, .privacyPolicy, .termsOfServie: - return true - case .marketingService: - return false - } - } + private var disposeBag = DisposeBag() + private var model: AgreementElement? - var isAddDiscription: Bool { - switch self { - case .locationService, .privacyPolicy, .termsOfServie: - return false - case .marketingService: - return true - } - } -} + var agreeBtnOnCliek: (() -> Void)? + var goWebviewBtnOnClick: (() -> Void)? -final class ServiceAgreementRowView: TFBaseView { + lazy var checkmark = UIImageView().then { + $0.image = DSKitAsset.Image.Component.check.image + } - private let serviceType: AgreementType - lazy var agreeBtn: UIButton = UIButton().then { - $0.setImage(DSKitAsset.Image.Component.check.image, for: .normal) - $0.setTitle(serviceType.labelTitle, for: .normal) - $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6) - $0.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6) - $0.titleLabel?.font = .thtSubTitle1R - $0.setTitleColor(DSKitAsset.Color.neutral50.color, for: .normal) + lazy var titleLabel = UILabel().then { + $0.font = .thtSubTitle1R + $0.numberOfLines = 2 + $0.textColor = DSKitAsset.Color.neutral50.color + $0.lineBreakStrategy = .hangulWordPriority } lazy var goWebviewBtn: UIButton = UIButton().then { $0.setImage(DSKitAsset.Image.Component.chevronRight.image.withRenderingMode(.alwaysTemplate), for: .normal) $0.imageView?.contentMode = .scaleAspectFit $0.tintColor = DSKitAsset.Color.neutral400.color + $0.addAction(UIAction { [weak self] _ in + self?.goWebviewBtnOnClick?() + }, for: .touchUpInside) } - private lazy var discriptionText: UILabel = UILabel().then { - $0.text = "폴링에서 제공하는 이벤트/혜택 등 다양한 정보를\nPush 알림으로 받아보실 수 있습니다." + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + lazy var descriptionText: UILabel = UILabel().then { $0.font = .thtCaption1M $0.textColor = DSKitAsset.Color.neutral400.color $0.numberOfLines = 2 } - init(serviceType: AgreementType) { - self.serviceType = serviceType - super.init(frame: .zero) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + override func prepareForReuse() { + self.disposeBag = DisposeBag() + super.prepareForReuse() + agreeBtnOnCliek = nil + goWebviewBtnOnClick = nil } - override func makeUI() { - addSubviews(agreeBtn, goWebviewBtn, discriptionText) + private lazy var containerView = UIView() + + func makeUI() { + contentView.addSubview(goWebviewBtn) + contentView.addSubview(checkmark) + contentView.addSubview(titleLabel) + contentView.addSubview(descriptionText) + contentView.backgroundColor = DSKitAsset.Color.neutral700.color + goWebviewBtn.snp.makeConstraints { + $0.size.equalTo(30) + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview() + } - agreeBtn.snp.makeConstraints { - $0.leading.top.bottom.equalToSuperview() + titleLabel.snp.makeConstraints { + $0.leading.equalTo(checkmark.snp.trailing).offset(10) + $0.top.equalToSuperview().offset(10) + $0.trailing.equalTo(goWebviewBtn.snp.leading) } - if serviceType.isConnectWebView { - goWebviewBtn.snp.makeConstraints { - $0.top.bottom.trailing.equalToSuperview() - $0.width.height.equalTo(24) - } - } else { - goWebviewBtn.isHidden = true + checkmark.snp.makeConstraints { + $0.size.equalTo(20) + $0.top.equalTo(titleLabel).offset(5) + $0.leading.equalToSuperview() } - if serviceType.isAddDiscription { - discriptionText.snp.makeConstraints { - $0.top.equalTo(agreeBtn.snp.bottom).offset(2) - $0.leading.equalToSuperview().offset(30) - } - } else { - discriptionText.isHidden = true + descriptionText.setContentHuggingPriority(.defaultHigh, for: .vertical) + descriptionText.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.leading.equalTo(titleLabel) + $0.trailing.equalTo(titleLabel) + $0.height.lessThanOrEqualTo(30) + $0.bottom.equalToSuperview().offset(-5) } } + func bind(_ viewModel: ServiceAgreementRowViewModel) { + self.model = viewModel.model + self.titleLabel.text = viewModel.model.subject + self.descriptionText.text = viewModel.model.description + self.checkmark.image = viewModel.checkImage.image + goWebviewBtn.isHidden = (viewModel.model.detailLink ?? "").isEmpty + } +} + +struct ServiceAgreementRowViewModel { + let model: AgreementElement + var checkImage: DSKitImages } +// +//#if canImport(SwiftUI) && DEBUG +//import SwiftUI +// +//struct ServiceAgreementRowViewPreview: PreviewProvider { +// +// static var previews: some View { +// UIViewPreview { +// let view = ServiceAgreementRowView() +// return ServiceAgreementRowView() +// } +// .frame(width: 375, height: 100) +// .previewLayout(.sizeThatFits) +// } +//} +//#endif diff --git a/Projects/Features/SignUp/Src/SubView/TFButtonPickerView.swift b/Projects/Features/SignUp/Src/SubView/TFButtonPickerView.swift new file mode 100644 index 00000000..3bf81c6d --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TFButtonPickerView.swift @@ -0,0 +1,110 @@ +// +// TFButtonPickerView.swift +// SignUp +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit + +import DSKit + +class TFButtonPickerView: UIControl { + enum TitleType { + case header + case sub + } + + struct Option: Equatable { + let key: Int + let value: String + }// = (key: Int, value: String) + + private let title: String + private let targetString: String + private var options: [CTAButton] = [] + + var selectedOption: Option? = nil { + didSet { + if let selectedOption { + changeButtonStatus(selectedOption) + sendActions(for: .valueChanged) + } + } + } + + private let titleType: TitleType + + init(title: String, targetString: String, options: [String], titleType: TitleType = .header) { + self.title = title + self.targetString = targetString + self.titleType = titleType + + super.init(frame: .zero) + createButtons(options) + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + lazy var titleLabel = UILabel().then { + $0.text = title + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = self.titleType == .header ? .thtH1B : .thtH4M + $0.asColor(targetString: targetString, color: DSKitAsset.Color.neutral50.color) + } + + lazy var buttonHStackView = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .fillEqually + $0.alignment = .fill + $0.spacing = 10 + } + + func makeUI() { + + addSubviews( + titleLabel, + buttonHStackView + ) + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(frame.height * 0.09) + $0.leading.trailing.equalToSuperview().inset(38) + } + + buttonHStackView.snp.makeConstraints { + $0.leading.trailing.equalTo(titleLabel) + $0.top.equalTo(titleLabel.snp.bottom).offset(30) + $0.height.equalTo(50) + $0.bottom.equalToSuperview().offset(-10) + } + } + + func createButtons(_ pairs: [String]) { + pairs.enumerated().forEach { index, value in + let button = CTAButton(btnTitle: value, initialStatus: false) + button.addAction(UIAction { [weak self] _ in + self?.handleSelectedState(Option(key: index, value: value)) + }, for: .touchUpInside) + self.buttonHStackView.addArrangedSubview(button) + self.options.append(button) + } + } + + func handleSelectedState(_ option: Option) { + self.selectedOption = option + } + + func changeButtonStatus(_ selectedOption: Option) { + options.enumerated().forEach { (index, element) in + if index == selectedOption.key { + element.updateColors(status: true) + } else { + element.updateColors(status: false) + } + } + } +} diff --git a/Projects/Features/SignUp/Src/SubView/TFCheckButton.swift b/Projects/Features/SignUp/Src/SubView/TFCheckButton.swift new file mode 100644 index 00000000..ea8125ff --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TFCheckButton.swift @@ -0,0 +1,54 @@ +// +// TFCheckButton.swift +// SignUp +// +// Created by Kanghos on 5/30/24. +// + +import UIKit + +import RxSwift + +import DSKit + +final class TFCheckButton: UIButton { + + init(btnTitle: String, initialStatus: Bool) { + super.init(frame: .zero) + makeUI(title: btnTitle) + updateColors(status: initialStatus) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func makeUI(title: String) { + setTitleColor(DSKitAsset.Color.neutral50.color, for: .normal) + self.setTitle(title, for: .normal) + self.imageEdgeInsets = UIEdgeInsets(top: 0, left: -200, bottom: 0, right: 0) + self.titleEdgeInsets = UIEdgeInsets(top: 0, left: -30, bottom: 0, right: 0) + + self.layer.cornerRadius = 16 + self.layer.masksToBounds = true + } + + /// update CTA button color by status + func updateColors(status: Bool) { + if status { + backgroundColor = DSKitAsset.Color.payment.color + setImage(DSKitAsset.Image.Component.checkCirSelect.image, for: .normal) + } else { + backgroundColor = DSKitAsset.Color.neutral600.color + setImage(DSKitAsset.Image.Component.checkCir.image, for: .normal) + } + } +} + +extension Reactive where Base: TFCheckButton { + var buttonStatus: Binder { + return Binder(base.self) { btn, status in + btn.updateColors(status: status) + } + } +} diff --git a/Projects/Features/SignUp/Src/SubView/TFGenderPickerView.swift b/Projects/Features/SignUp/Src/SubView/TFGenderPickerView.swift new file mode 100644 index 00000000..44c0bb07 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TFGenderPickerView.swift @@ -0,0 +1,8 @@ +// +// TFGenderPickerView.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import Foundation diff --git a/Projects/Features/SignUp/Src/SubView/TFResizableTextView.swift b/Projects/Features/SignUp/Src/SubView/TFResizableTextView.swift new file mode 100644 index 00000000..df345c4d --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TFResizableTextView.swift @@ -0,0 +1,287 @@ +// +// TFResizableTextView.swift +// SignUp +// +// Created by kangho lee on 4/27/24. +// + +import UIKit + +import DSKit + +import RxSwift + +public class TFResizableTextView: UIControl { + private let maxHeight: CGFloat = 120 + private let minHeight: CGFloat = 40 + private var totalCount: Int = 20 + + enum State { + case text(text: String?) + case error(error: InputError) + case focus + case focusOut + } + + enum InputError: Error { + case validate(text: String) + case overflow + } + + var text: String? { + set { + self.textView.text = newValue + } get { + return self.textView.text + } + } + + var placeholder: String? { + didSet { + placeholderLabel.text = placeholder + } + } + + private lazy var placeholderLabel = UILabel().then { + $0.textColor = DSKitAsset.Color.disabled.color + $0.font = .thtSubTitle1R + $0.backgroundColor = .clear + } + + lazy var textView = ResizableTextView().then { + $0.textColor = DSKitAsset.Color.primary500.color + $0.font = .thtSubTitle1R + $0.isScrollEnabled = false + $0.isEditable = true + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + + lazy var clearBtn: UIButton = UIButton().then { + $0.setImage(DSKitAsset.Image.Icons.closeCircle.image, for: .normal) + $0.setTitle(nil, for: .normal) + $0.backgroundColor = .clear + $0.isHidden = true + } + + lazy var divider: UIView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral300.color + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + lazy var errorDescriptionLabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.error.color + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + lazy var countLabel = UILabel().then { + $0.text = "(0/\(totalCount)" + $0.font = .thtCaption1R + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 2 + } + + public init(description: String = "", totalCount: Int = 20, placeholder: String? = nil) { + self.totalCount = totalCount + super.init(frame: .zero) + self.descLabel.text = description + + makeUI() + bindAction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func makeUI() { + addSubviews( + textView, clearBtn, + placeholderLabel, + divider, + errorDescriptionLabel, + infoImageView, descLabel, countLabel + ) + + textView.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.leading.equalToSuperview() + $0.trailing.equalTo(clearBtn.snp.leading) + } + + clearBtn.snp.makeConstraints { + $0.leading.equalTo(textView.snp.trailing) + $0.bottom.equalTo(textView) + $0.trailing.equalToSuperview() + $0.size.equalTo(24) + } + + placeholderLabel.snp.makeConstraints { + $0.top.leading.trailing.equalTo(textView) + $0.height.equalTo(40) + } + + divider.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(textView.snp.bottom).offset(2) + $0.height.equalTo(2) + } + + errorDescriptionLabel.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(divider.snp.bottom) + $0.height.equalTo(20).priority(.low) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(textView) + $0.size.equalTo(16) + $0.top.equalTo(errorDescriptionLabel.snp.bottom) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.trailing.equalTo(countLabel.snp.leading) + $0.top.equalTo(infoImageView) + $0.bottom.equalToSuperview() + } + + countLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + countLabel.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.top.equalTo(descLabel) + } + } + + func bindAction() { + textView.delegate = self + + clearBtn.addAction(UIAction(handler: { [weak self] _ in + self?.text = "" + self?.clearBtn.isHidden = true + self?.sendActions(for: .editingChanged) + self?.checkPlaceholderLabel("") + }), for: .touchUpInside) + + updateDividerColor(.focusOut) + } + + private func calculateCount(count: Int) { + let fullText = "(\(count)/\(totalCount))" + let target = "\(count)" + self.countLabel.text = fullText + self.countLabel.asFont(targetString: target, font: .thtCaption1B) + + if count > totalCount { + self.render(state: .error(error: .overflow)) + if let text { + let endIndex = text.index(text.startIndex, offsetBy: 200) + let range = text[text.startIndex.. { + self.base.textView.rx.text + } +} +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct TFResizableTextViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = TFResizableTextView() + return component + } + .frame(width: 375, height: 120) + .previewLayout(.sizeThatFits) + } +} +#endif + diff --git a/Projects/Features/SignUp/Src/SubView/TFTextField.swift b/Projects/Features/SignUp/Src/SubView/TFTextField.swift new file mode 100644 index 00000000..9c965830 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TFTextField.swift @@ -0,0 +1,234 @@ +// +// TFTextField.swift +// SignUpInterface +// +// Created by Kanghos on 2024/04/18. +// + +import UIKit + +import DSKit + +// TODO: RxExtension 및 기존 프로퍼티 private +// TODO: textField focus +class TFTextField: UIControl { + + enum State { + case text(text: String?) + case error(error: InputError) + } + + enum InputError: Error { + case validate(text: String) + case overflow + } + + var text: String? { + set { + self.textField.text = newValue + self.divider.backgroundColor = (newValue?.isEmpty ?? true) == false + ? DSKitAsset.Color.primary500.color + : DSKitAsset.Color.neutral300.color + self.calculateCount(count: newValue?.count ?? 0) + } get { + return self.textField.text + } + } + + var placeholder: String? = nil { + didSet { + self.textField.placeholder = placeholder + } + } + + /// set Total Count + private var totalCount: Int + + var hasText: Bool { + return (text?.isEmpty ?? true) == false + } + + lazy var textField: UITextField = UITextField().then { + $0.placeholder = "입력" + $0.textColor = DSKitAsset.Color.primary500.color + $0.font = .thtH2B + $0.autocapitalizationType = .none +// $0.keyboardType = .numberPad + } + + lazy var clearBtn: UIButton = UIButton().then { + $0.setImage(DSKitAsset.Image.Icons.closeCircle.image, for: .normal) + $0.setTitle(nil, for: .normal) + $0.backgroundColor = .clear + $0.isHidden = true + } + + lazy var divider: UIView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral300.color + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + lazy var errorDescriptionLabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.error.color + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + lazy var countLabel = UILabel().then { + $0.text = "(0/12)" + $0.font = .thtCaption1R + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 2 + } + + init(description: String = "", totalCount: Int = 20, placeholder: String? = nil) { + self.totalCount = totalCount + super.init(frame: .zero) + self.descLabel.text = description + self.placeholder = placeholder + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func makeUI() { + addSubviews( + textField, clearBtn, + divider, + errorDescriptionLabel, + infoImageView, descLabel, countLabel + ) + + textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + textField.snp.makeConstraints { + $0.top.leading.equalToSuperview() + } + + clearBtn.snp.makeConstraints { + $0.leading.equalTo(textField.snp.trailing) + $0.centerY.equalTo(textField) + $0.trailing.equalToSuperview() + $0.size.equalTo(24) + } + + divider.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(textField.snp.bottom).offset(2) + $0.height.equalTo(2) + } + + errorDescriptionLabel.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(divider.snp.bottom) + $0.height.equalTo(20).priority(.low) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(textField) + $0.size.equalTo(16) + $0.top.equalTo(errorDescriptionLabel.snp.bottom) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.trailing.equalTo(countLabel.snp.leading) + $0.top.equalTo(infoImageView) + $0.bottom.equalToSuperview() + } + + countLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + countLabel.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.top.equalTo(descLabel) + } + + calculateCount(count: 0) + bindAction() + } + + func bindAction() { + clearBtn.addAction(UIAction(handler: { [weak self] _ in + self?.textField.text = "" + self?.clearBtn.isHidden = true + self?.textField.sendActions(for: .editingChanged) + self?.sendActions(for: .editingChanged) + }), for: .touchUpInside) + + textField.addAction(UIAction(handler: { [weak self] _ in + self?.clearBtn.isHidden = self?.textField.text?.isEmpty == true + self?.text = self?.textField.text + self?.render(state: .text(text: self?.text)) + if let totalCount = self?.totalCount, let count = self?.text?.count, count > totalCount { + self?.render(state: .error(error: InputError.overflow)) + } + self?.sendActions(for: .editingChanged) + }), for: .editingChanged) + } + + override func becomeFirstResponder() -> Bool { + self.textField.becomeFirstResponder() + } + + func render(state: State) { + switch state { + case let .text(text): + self.errorDescriptionLabel.isHidden = true + self.text = text + self.errorDescriptionLabel.text = "" + + case .error(let inputError): + self.errorDescriptionLabel.isHidden = false + self.divider.backgroundColor = DSKitAsset.Color.error.color + + if case let .validate(description) = inputError { + self.errorDescriptionLabel.text = description + self.countLabel.asColor(targetString: "\(self.text?.count ?? 0)", color: DSKitAsset.Color.neutral400.color) + } + if case .overflow = inputError { + self.errorDescriptionLabel.text = "\(self.totalCount)자 이상 입력할 수 없습니다." + self.countLabel.asColor(targetString: "\(self.text?.count ?? 0)", color: DSKitAsset.Color.error.color) + } + } + } + + private func calculateCount(count: Int) { + let fullText = "(\(count)/\(totalCount))" + let target = "\(count)" + self.countLabel.text = fullText + self.countLabel.asFont(targetString: target, font: .thtCaption1B) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct TFTextFieldPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = TFTextField() + component.render(state: .text(text: "닉네임닉네임")) + component.render(state: .error(error: .overflow)) + component.render(state: .error(error: .validate(text: "중복된 닉네임입니다."))) +// component.render(state: .text(text: nil)) + return component + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SubView/TFTextView.swift b/Projects/Features/SignUp/Src/SubView/TFTextView.swift new file mode 100644 index 00000000..e3fd8aa4 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TFTextView.swift @@ -0,0 +1,261 @@ +// +// TFTextView.swift +// SignUp +// +// Created by Kanghos on 2024/04/24. +// + +import UIKit + +import DSKit + +// TODO: RxExtension 및 기존 프로퍼티 private +// TODO: textField focus +class TFTextView: UIControl { + private let maxHeight: CGFloat = 120 + private let minHeight: CGFloat = 40 + private var textViewHeightConstraint: NSLayoutConstraint? + + enum State { + case text(text: String?) + case error(error: InputError) + case focus + case focusOut + } + + enum InputError: Error { + case validate(text: String) + case overflow + } + + var text: String? { + set { + self.textField.text = newValue + self.calculateCount(count: newValue?.count ?? 0) + } get { + return self.textField.text + } + } + + /// set Total Count + private var totalCount: Int + + lazy var textField = UITextView().then { + $0.textColor = DSKitAsset.Color.primary500.color + $0.font = .thtSubTitle1R + $0.isScrollEnabled = false + $0.isEditable = true + $0.keyboardType = .asciiCapable + } + + lazy var clearBtn: UIButton = UIButton().then { + $0.setImage(DSKitAsset.Image.Icons.closeCircle.image, for: .normal) + $0.setTitle(nil, for: .normal) + $0.backgroundColor = .clear + $0.isHidden = true + } + + lazy var divider: UIView = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral300.color + } + + lazy var infoImageView: UIImageView = UIImageView().then { + $0.image = DSKitAsset.Image.Icons.explain.image.withRenderingMode(.alwaysTemplate) + $0.tintColor = DSKitAsset.Color.neutral400.color + } + + lazy var descLabel: UILabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + lazy var errorDescriptionLabel = UILabel().then { + $0.font = .thtCaption1M + $0.textColor = DSKitAsset.Color.error.color + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + lazy var countLabel = UILabel().then { + $0.text = "(0/12)" + $0.font = .thtCaption1R + $0.textColor = DSKitAsset.Color.neutral400.color + $0.textAlignment = .left + $0.numberOfLines = 2 + } + + init(description: String = "", totalCount: Int = 20, placeholder: String? = nil) { + self.totalCount = totalCount + super.init(frame: .zero) + self.descLabel.text = description + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func makeUI() { + addSubviews( + textField, clearBtn, + divider, + errorDescriptionLabel, + infoImageView, descLabel, countLabel + ) + + textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + textField.snp.makeConstraints { + $0.top.leading.equalToSuperview() + $0.trailing.equalTo(clearBtn.snp.leading) + } + self.textViewHeightConstraint = textField.heightAnchor.constraint(equalToConstant: minHeight) + self.textViewHeightConstraint?.isActive = true + + clearBtn.snp.makeConstraints { + $0.leading.equalTo(textField.snp.trailing) + $0.bottom.equalTo(textField) + $0.trailing.equalToSuperview() + $0.size.equalTo(24) + } + + divider.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(textField.snp.bottom).offset(2) + $0.height.equalTo(2) + } + + errorDescriptionLabel.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(divider.snp.bottom) + $0.height.equalTo(20).priority(.low) + } + + infoImageView.snp.makeConstraints { + $0.leading.equalTo(textField) + $0.size.equalTo(16) + $0.top.equalTo(errorDescriptionLabel.snp.bottom) + } + + descLabel.snp.makeConstraints { + $0.leading.equalTo(infoImageView.snp.trailing).offset(6) + $0.trailing.equalTo(countLabel.snp.leading) + $0.top.equalTo(infoImageView) + $0.bottom.equalToSuperview() + } + + countLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + countLabel.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.top.equalTo(descLabel) + } + + calculateCount(count: 0) + bindAction() + } + + func bindAction() { + clearBtn.addAction(UIAction(handler: { [weak self] _ in + self?.textField.text = "" + self?.clearBtn.isHidden = true + self?.sendActions(for: .editingChanged) + }), for: .touchUpInside) + + textField.delegate = self + updateDividerColor(.focusOut) + } + + func render(state: State) { + switch state { + case let .text(text): + self.errorDescriptionLabel.isHidden = true + self.text = text + self.errorDescriptionLabel.text = "" + + case .error(let inputError): + self.updateErrorView(inputError) + default: break + } + } + + private func calculateCount(count: Int) { + let fullText = "(\(count)/\(totalCount))" + let target = "\(count)" + self.countLabel.text = fullText + self.countLabel.asFont(targetString: target, font: .thtCaption1B) + + if count > totalCount { + self.render(state: .error(error: .overflow)) + } + } +} + +extension TFTextView: UITextViewDelegate { + public func textViewDidBeginEditing(_ textView: UITextView) { + updateDividerColor(.focus) + } + + public func textViewDidEndEditing(_ textView: UITextView) { + updateDividerColor(.focusOut) + } + + public func textViewDidChange(_ textView: UITextView) { + updateDividerColor(.focusOut) + self.calculateCount(count: textView.text.count) + self.sendActions(for: .valueChanged) + updateDividerColor(.text(text: textField.text)) + } + + func updateDividerColor(_ state: State) { + switch state { + case .error: + self.divider.backgroundColor = DSKitAsset.Color.error.color + case .focus: + self.divider.backgroundColor = DSKitAsset.Color.primary500.color + default: + self.divider.backgroundColor = self.textField.text.isEmpty + ? DSKitAsset.Color.neutral300.color + : DSKitAsset.Color.primary500.color + } + self.clearBtn.isHidden = self.textField.text.isEmpty + } + + func updateErrorView(_ inputError: InputError) { + self.errorDescriptionLabel.isHidden = false + + if case let .validate(description) = inputError { + self.errorDescriptionLabel.text = description + self.countLabel.asColor(targetString: "\(self.text?.count ?? 0)", color: DSKitAsset.Color.neutral400.color) + } + if case .overflow = inputError { + self.errorDescriptionLabel.text = "\(self.totalCount)자 이상 입력할 수 없습니다." + self.countLabel.asColor(targetString: "\(self.text?.count ?? 0)", color: DSKitAsset.Color.error.color) + } + updateDividerColor(.error(error: inputError)) + } + +} + +extension Reactive where Base: TFTextView { + var text: ControlProperty { + self.base.textField.rx.text + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct TFTextViewPreview: PreviewProvider { + + static var previews: some View { + UIViewPreview { + let component = TFTextView() +// component.render(state: .error(error: .validate(text: "중복된 닉네임입니다."))) + return component + } + .frame(width: 375, height: 120) + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Projects/Features/SignUp/Src/SubView/TagPickerView.swift b/Projects/Features/SignUp/Src/SubView/TagPickerView.swift new file mode 100644 index 00000000..c13017c9 --- /dev/null +++ b/Projects/Features/SignUp/Src/SubView/TagPickerView.swift @@ -0,0 +1,103 @@ +// +// InterestPickerView.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit +import SignUpInterface + +import DSKit + +final class TagPickerView: TFBaseView { + struct AttributedTitleInfo { + let title: String + let targetText: String + } + + private let titleInfo: AttributedTitleInfo + private let subTitleInfo: AttributedTitleInfo + + init(titleInfo: AttributedTitleInfo, subTitleInfo: AttributedTitleInfo) { + self.titleInfo = titleInfo + self.subTitleInfo = subTitleInfo + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + lazy var container = UIView().then { + $0.backgroundColor = DSKitAsset.Color.neutral700.color + } + lazy var titleLabel: UILabel = UILabel().then { + $0.text = self.titleInfo.title + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH1B + $0.asColor(targetString: self.titleInfo.targetText, color: DSKitAsset.Color.neutral50.color) + } + + lazy var subTitleLabel: UILabel = UILabel().then { + $0.text = self.subTitleInfo.title + $0.textColor = DSKitAsset.Color.neutral300.color + $0.font = .thtH4B + $0.asColor(targetString: self.subTitleInfo.targetText, color: DSKitAsset.Color.neutral50.color) + } + + lazy var collectionView: UICollectionView = { + let layout = LeftAlignCollectionViewFlowLayout(sidePadding: 0) + layout.scrollDirection = .vertical + layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(cellType: InputTagCollectionViewCell.self) + collectionView.isScrollEnabled = true + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + return collectionView + }() + + lazy var nextBtn = CTAButton(btnTitle: "->", initialStatus: false) + + override func makeUI() { + addSubview(container) + + container.addSubviews( + titleLabel, + subTitleLabel, + collectionView, + nextBtn + ) + + container.snp.makeConstraints { + $0.top.leading.trailing.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(76) + $0.leading.trailing.equalToSuperview().inset(30) + } + + subTitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(30) + $0.leading.trailing.equalTo(titleLabel) + } + + collectionView.setContentHuggingPriority(.defaultLow, for: .vertical) + collectionView.snp.makeConstraints { + $0.top.equalTo(subTitleLabel.snp.bottom).offset(30) + $0.leading.trailing.equalTo(titleLabel) + $0.height.equalTo(350).priority(.low) + } + + nextBtn.snp.makeConstraints { + $0.top.equalTo(collectionView.snp.bottom).offset(30) + $0.trailing.equalTo(titleLabel) + $0.height.equalTo(50) + $0.width.equalTo(88) + } + } +} diff --git a/Projects/Features/SignUp/Src/UseCase/SignUpUseCase.swift b/Projects/Features/SignUp/Src/UseCase/SignUpUseCase.swift new file mode 100644 index 00000000..cd7cdca3 --- /dev/null +++ b/Projects/Features/SignUp/Src/UseCase/SignUpUseCase.swift @@ -0,0 +1,111 @@ +// +// SignUpUseCase.swift +// SignUpInterface +// +// Created by kangho lee on 5/1/24. +// + +import Foundation +import RxSwift +import SignUpInterface +import AuthInterface +import Domain + +public final class SignUpUseCase: SignUpUseCaseInterface { + + private let repository: SignUpRepositoryInterface + private let tokenStore: TokenStore + private let locationService: LocationServiceType + private let kakaoAPIService: KakaoAPIServiceType + private let contactService: ContactServiceType + + public init(repository: SignUpRepositoryInterface, locationService: LocationServiceType, kakaoAPIService: KakaoAPIServiceType, contactService: ContactServiceType, tokenStore: TokenStore) { + self.repository = repository + self.kakaoAPIService = kakaoAPIService + self.contactService = contactService + self.locationService = locationService + self.tokenStore = tokenStore + } + + public func checkNickname(nickname: String) -> Single { + return repository.checkNickname(nickname: nickname) + } + + public func idealTypes() -> Single<[Domain.EmojiType]> { + return repository.idealTypes() + .catchAndReturn([]) + .map { $0.map { $0.toDomain() }} + } + + public func interests() -> Single<[Domain.EmojiType]> { + return repository.interests() + .catchAndReturn([]) + .map { $0.map { $0.toDomain() }} + } + + public func block() -> Single<[ContactType]> { + self.contactService.fetchContact() + .map { contacts in + contacts.map { contact in + let phoneNumber = contact.phoneNumber.replacingOccurrences(of: "\\D", with: "", options: .regularExpression) + return ContactType(name: contact.name, phoneNumber: phoneNumber) + } + } + } + + public func signUp(request: SignUpReq) -> Single { + return repository.signUp(request) + .flatMap { [weak self] token in + self?.tokenStore.saveToken(token: token) + return .just(()) + } + } + + public func uploadImage(data: [Data]) -> Single<[String]> { + return repository.uploadImage(data: data) + } + + public func fetchAgreements() -> Single { + repository.fetchAgreements() + } + + @MainActor + public func fetchLocation() -> Single { // + self.locationService.requestAuthorization() + + self.locationService.handleAuthorization { [weak self] granted in + guard granted else { + return + } + self?.locationService.requestLocation() + } + + return self.locationService.publisher + .take(1) + .asSingle() + .flatMap { [unowned self] locationReq in + self.kakaoAPIService.fetchLocationByCoordinate2d(longitude: locationReq.lon, latitude: locationReq.lat) + }.map { model in + guard let model else { + throw LocationError.invalidLocation + } + return model + } + } + + public func fetchLocation(_ address: String) -> Single { + self.kakaoAPIService.fetchLocationByAddress(address: address) + .map { model in + guard let model else { + throw LocationError.invalidLocation + } + return model + } + } +} + +extension SignUpInterface.EmojiType { + func toDomain() -> Domain.EmojiType { + Domain.EmojiType(idx: self.index, name: self.name, emojiCode: self.emojiCode) + } +} diff --git a/Projects/Features/SignUp/Src/UseCase/UserInfoUseCase.swift b/Projects/Features/SignUp/Src/UseCase/UserInfoUseCase.swift new file mode 100644 index 00000000..045e05b6 --- /dev/null +++ b/Projects/Features/SignUp/Src/UseCase/UserInfoUseCase.swift @@ -0,0 +1,47 @@ +// +// UserInfoUseCase.swift +// SignUpInterface +// +// Created by Kanghos on 5/29/24. +// + +import Foundation +import SignUpInterface +import RxSwift + +public class UserInfoUseCase: UserInfoUseCaseInterface { + private let repository: UserInfoRepositoryInterface + + public init(repository: UserInfoRepositoryInterface) { + self.repository = repository + } + + public func savePhoneNumber(_ phoneNumber: String) { + repository.savePhoneNumber(phoneNumber) + repository.deleteUserInfo() + } + + public func fetchPhoneNumber() -> Single { + repository.fetchPhoneNumber() + } + + public func fetchUserInfo() -> Single { + repository.fetchUserInfo() + } + + public func updateUserInfo(userInfo: UserInfo) { + return repository.updateUserInfo(userInfo: userInfo) + } + + public func deleteUserInfo() { + return repository.deleteUserInfo() + } + + public func fetchUserPhotos(key: String, fileNames: [String]) -> Single<[Data]> { + return repository.fetchUserPhotos(key: key, fileNames: fileNames) + } + + public func saveUserPhotos(key: String, datas: [Data]) -> Single<[String]> { + repository.saveUserPhotos(key: key, datas: datas) + } +} diff --git a/Projects/Features/SignUp/Src/Util/CollectionViewLayout+Util.swift b/Projects/Features/SignUp/Src/Util/CollectionViewLayout+Util.swift new file mode 100644 index 00000000..b8e4e336 --- /dev/null +++ b/Projects/Features/SignUp/Src/Util/CollectionViewLayout+Util.swift @@ -0,0 +1,43 @@ +// +// CollectionViewLayout+Util.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +extension UICollectionViewLayout { + static func photoPickLayout(edge: CGFloat = 5) -> UICollectionViewLayout { + let edgeInset = NSDirectionalEdgeInsets(top: edge, leading: edge, bottom: edge, trailing: edge) + + let layout = UICollectionViewCompositionalLayout { + (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in + + let leadingItem = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.65), + heightDimension: .fractionalHeight(1.0))) + leadingItem.contentInsets = edgeInset + + let trailingItem = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(0.5))) + trailingItem.contentInsets = edgeInset + + let trailingGroup = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.35), + heightDimension: .fractionalHeight(1.0)), + repeatingSubitem: trailingItem, count: 2) + + let nestedGroup = NSCollectionLayoutGroup.horizontal( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)), + subitems: [leadingItem, trailingGroup]) + let section = NSCollectionLayoutSection(group: nestedGroup) + return section + + } + return layout + } +} + diff --git a/Projects/Features/SignUp/Src/Util/Date+Util.swift b/Projects/Features/SignUp/Src/Util/Date+Util.swift new file mode 100644 index 00000000..92458ea8 --- /dev/null +++ b/Projects/Features/SignUp/Src/Util/Date+Util.swift @@ -0,0 +1,24 @@ +// +// Date+Util.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import Foundation + +// https://stackoverflow.com/questions/31590316/how-do-i-find-the-number-of-days-in-given-month-and-year-using-swift +extension Date { + func daysInMonth(month: Int, year: Int) -> Int { + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + if let d = Calendar.current.date(from: dateComponents), + let interval = Calendar.current.dateInterval(of: .month, for: d), + let days = Calendar.current.dateComponents([.day], from: interval.start, to: interval.end).day { + return days } + else { + return 30 + } + } +} diff --git a/Projects/Features/SignUp/Src/Util/UIButton+Util.swift b/Projects/Features/SignUp/Src/Util/UIButton+Util.swift new file mode 100644 index 00000000..e1c46e30 --- /dev/null +++ b/Projects/Features/SignUp/Src/Util/UIButton+Util.swift @@ -0,0 +1,27 @@ +// +// UIButton+Util.swift +// SignUp +// +// Created by Kanghos on 2024/04/21. +// + +import UIKit + +import DSKit + +extension UIButton { + static var plusButton: UIButton { + let button = UIButton() + var config = UIButton.Configuration.filled() + + config.baseBackgroundColor = DSKitAsset.Color.disabled.color + let imageConfig = UIImage.SymbolConfiguration(pointSize: 10) + config.image = UIImage(systemName: "plus", withConfiguration: imageConfig)?.withTintColor(DSKitAsset.Color.neutral50.color, renderingMode: .alwaysOriginal) + config.imagePadding = 14 + config.cornerStyle = .capsule + + button.configuration = config + + return button + } +} diff --git a/Projects/Features/SignUp/Src/Util/UILabel+Util.swift b/Projects/Features/SignUp/Src/Util/UILabel+Util.swift new file mode 100644 index 00000000..afa2ee1a --- /dev/null +++ b/Projects/Features/SignUp/Src/Util/UILabel+Util.swift @@ -0,0 +1,45 @@ +// +// UIFont+Util.swift +// SignUp +// +// Created by Kanghos on 2024/04/16. +// + +import UIKit +import DSKit + +extension UILabel { + @discardableResult + func asColor(targetString: String, color: UIColor) -> Self { + let fullText = self.text ?? "" + let range = (fullText as NSString).range(of: targetString) + var mutable: NSMutableAttributedString + + if let attributed = self.attributedText { + mutable = NSMutableAttributedString(attributedString: attributed) + } else { + mutable = NSMutableAttributedString(string: fullText) + } + mutable.addAttributes([.foregroundColor: color], range: range) + self.attributedText = mutable + return self + } + + @discardableResult + func asFont(targetString: String, font: UIFont) -> Self { + let fullText = self.text ?? "" + let range = (fullText as NSString).range(of: targetString) + + var mutable: NSMutableAttributedString + + if let attributed = self.attributedText { + mutable = NSMutableAttributedString(attributedString: attributed) + } else { + mutable = NSMutableAttributedString(string: fullText) + } + mutable.addAttribute(.font, value: font, range: range) + self.attributedText = mutable + + return self + } +} diff --git a/Projects/Features/Src/Application/Coordinator/AppCoordinator.swift b/Projects/Features/Src/Application/Coordinator/AppCoordinator.swift index 2661d21f..cdd6e9b0 100644 --- a/Projects/Features/Src/Application/Coordinator/AppCoordinator.swift +++ b/Projects/Features/Src/Application/Coordinator/AppCoordinator.swift @@ -10,42 +10,51 @@ import Foundation import Core import SignUpInterface +import AuthInterface import DSKit protocol AppCoordinating { - func signUpFlow() + func launchFlow() + func authFlow() func mainFlow() } final class AppCoordinator: LaunchCoordinator, AppCoordinating { private let mainBuildable: MainBuildable - private let signUpBuildable: SignUpBuildable + private let authBuildable: AuthBuildable + private let launchBuildable: LaunchBuildable init( viewControllable: ViewControllable, mainBuildable: MainBuildable, - signUpBuildable: SignUpBuildable + authBuildable: AuthBuildable, + launchBUidlable: LaunchBuildable ) { self.mainBuildable = mainBuildable - self.signUpBuildable = signUpBuildable + self.authBuildable = authBuildable + self.launchBuildable = launchBUidlable super.init(viewControllable: viewControllable) } public override func start() { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.selectFlow() - } + launchFlow() } - // MARK: - public - func signUpFlow() { - let signUpCoordinator = self.signUpBuildable.build() + func launchFlow() { + let coordinator = self.launchBuildable.build(rootViewControllable: self.viewControllable) + attachChild(coordinator) + coordinator.delegate = self + coordinator.start() + } - attachChild(signUpCoordinator) - signUpCoordinator.delegate = self + // MARK: - public + func authFlow() { + let coordinator = self.authBuildable.build() - signUpCoordinator.start() + attachChild(coordinator) + coordinator.delegate = self + coordinator.start() } func mainFlow() { @@ -56,26 +65,32 @@ final class AppCoordinator: LaunchCoordinator, AppCoordinating { mainCoordinator.start() } - - // MARK: - Private - private func selectFlow() { - mainFlow() - } } extension AppCoordinator: MainCoordinatorDelegate { func detachTab(_ coordinator: Coordinator) { detachChild(coordinator) - signUpFlow() + authFlow() } } -extension AppCoordinator: SignUpCoordinatorDelegate { - - func detachSignUp(_ coordinator: Coordinator) { +extension AppCoordinator: AuthCoordinatingDelegate { + func detachAuth(_ coordinator: Core.Coordinator) { detachChild(coordinator) - + mainFlow() } } + +extension AppCoordinator: LaunchCoordinatingDelegate { + func finishFlow(_ coordinator: Core.Coordinator, _ action: AuthInterface.LaunchAction) { + switch action { + case .needAuth: + authFlow() + case .toMain: + mainFlow() + } + detachChild(coordinator) + } +} diff --git a/Projects/Features/Src/Application/Coordinator/AppRootBuilder.swift b/Projects/Features/Src/Application/Coordinator/AppRootBuilder.swift index f782eed4..7e42c40b 100644 --- a/Projects/Features/Src/Application/Coordinator/AppRootBuilder.swift +++ b/Projects/Features/Src/Application/Coordinator/AppRootBuilder.swift @@ -9,8 +9,8 @@ import UIKit import Core import DSKit +import Auth import SignUp -import SignUpInterface public protocol AppRootBuildable { func build() -> LaunchCoordinating @@ -19,22 +19,31 @@ public protocol AppRootBuildable { public final class AppRootBuilder: AppRootBuildable { public init() { } - lazy var mainBuildable: MainBuildable = { + private lazy var mainBuildable: MainBuildable = { MainBuilder() }() - lazy var signUpBuildable: SignUpBuildable = { + private lazy var signUpBuildable: SignUpBuildable = { SignUpBuilder() }() + private lazy var authBuildable: AuthBuildable = { + AuthBuilder(signUpBuilable: signUpBuildable) + }() + + private lazy var launchBuildable: LaunchBuildable = { + LaunchBuilder() + }() + public func build() -> LaunchCoordinating { - let viewController = TFLaunchViewController() + let viewController = NavigationViewControllable() let coordinator = AppCoordinator( viewControllable: viewController, mainBuildable: self.mainBuildable, - signUpBuildable: self.signUpBuildable + authBuildable: self.authBuildable, + launchBUidlable: self.launchBuildable ) return coordinator } diff --git a/Projects/Modules/DesignSystem/Resources/Color.xcassets/THTOrange400.colorset/Contents.json b/Projects/Modules/DesignSystem/Resources/Color.xcassets/THTOrange400.colorset/Contents.json new file mode 100644 index 00000000..70be8277 --- /dev/null +++ b/Projects/Modules/DesignSystem/Resources/Color.xcassets/THTOrange400.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x39", + "green" : "0x75", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Modules/DesignSystem/Src/BaseViewController/TFBaseViewController.swift b/Projects/Modules/DesignSystem/Src/BaseViewController/TFBaseViewController.swift index 11353241..82c5636e 100644 --- a/Projects/Modules/DesignSystem/Src/BaseViewController/TFBaseViewController.swift +++ b/Projects/Modules/DesignSystem/Src/BaseViewController/TFBaseViewController.swift @@ -14,13 +14,18 @@ open class TFBaseViewController: UIViewController, ViewControllable { super.init(nibName: nil, bundle: nil) TFLogger.cycle(name: self) } + + open override func loadView() { + super.loadView() + + view.backgroundColor = DSKitAsset.Color.neutral700.color + } open override func viewDidLoad() { super.viewDidLoad() TFLogger.cycle(name: self) -// self.view.backgroundColor = DSKitAsset.Color.neutral700.color makeUI() bindViewModel() navigationSetting() @@ -39,18 +44,27 @@ open class TFBaseViewController: UIViewController, ViewControllable { open func bindViewModel() { } +// https://ios-development.tistory.com/697 open func navigationSetting() { -// navigationController?.navigationBar.topItem?.title = "" - navigationController?.navigationBar.backIndicatorImage = DSKitAsset.Image.Icons.chevron.image - navigationController?.navigationBar.backIndicatorTransitionMaskImage = DSKitAsset.Image.Icons.chevron.image - navigationController?.navigationBar.tintColor = DSKitAsset.Color.neutral50.color - + + let backButtonImage = DSKitAsset.Image.Icons.chevron.image.withAlignmentRectInsets(.init(top: 0, left: -10, bottom: 0, right: 0)) + + let backButtonAppearence = UIBarButtonItemAppearance() + backButtonAppearence.normal.titleTextAttributes = [.foregroundColor: UIColor.clear, .font: UIFont.systemFont(ofSize: 0)] + let navBarAppearance = UINavigationBarAppearance() navBarAppearance.titlePositionAdjustment.horizontal = -CGFloat.greatestFiniteMagnitude navBarAppearance.titleTextAttributes = [.font: UIFont.thtH4Sb, .foregroundColor: DSKitAsset.Color.neutral50.color] navBarAppearance.backgroundColor = DSKitAsset.Color.neutral700.color - navBarAppearance.shadowColor = nil - navigationItem.standardAppearance = navBarAppearance - navigationItem.scrollEdgeAppearance = navBarAppearance + navBarAppearance.shadowColor = .clear + navBarAppearance.setBackIndicatorImage(backButtonImage, transitionMaskImage: backButtonImage) + navBarAppearance.backButtonAppearance = backButtonAppearence + + // Bar button title color + navigationController?.navigationBar.tintColor = DSKitAsset.Color.neutral50.color + + navigationController?.navigationBar.standardAppearance = navBarAppearance + navigationController?.navigationBar.scrollEdgeAppearance = navBarAppearance + navigationController?.navigationBar.isTranslucent = false } } diff --git a/Projects/Modules/DesignSystem/Src/BaseViewController/TFLaunchVIewController.swift b/Projects/Modules/DesignSystem/Src/BaseViewController/TFLaunchVIewController.swift index bbf224db..50ea1c6c 100644 --- a/Projects/Modules/DesignSystem/Src/BaseViewController/TFLaunchVIewController.swift +++ b/Projects/Modules/DesignSystem/Src/BaseViewController/TFLaunchVIewController.swift @@ -9,19 +9,40 @@ import UIKit import Lottie -public final class TFLaunchViewController: TFBaseViewController { +open class TFLaunchViewController: TFBaseViewController { private lazy var splashLottieView = LottieAnimationView(animation: AnimationAsset.logoSplash.animation) - + public override func loadView() { super.loadView() view.backgroundColor = DSKitAsset.Color.neutral700.color - - self.view.addSubview(splashLottieView) + // + // self.view.addSubview(splashLottieView) + // splashLottieView.snp.makeConstraints { + // $0.center.equalToSuperview() + // $0.height.width.equalTo(view.bounds.height * 0.7) + // .inset(view.bounds.height * 0.162) + // } + // splashLottieView.snp.makeConstraints { + // $0.centerX.equalToSuperview() + // $0.top.equalTo(view.safeAreaLayoutGuide) + // $0.height.width.equalTo(view.bounds.height * 0.7) + // .inset(view.bounds.height * 0.162) + //// $0.height.equalTo(180) + // } + // + // splashLottieView.play() + } + open override func makeUI() { + view.addSubview(splashLottieView) + splashLottieView.contentMode = .scaleAspectFit + splashLottieView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.height.width.equalTo(view.bounds.height * 0.7) + $0.edges.equalToSuperview().inset(10) + // $0.height.width.equalTo(view.bounds.height * 0.7) + // .inset(view.bounds.height * 0.162) } - + splashLottieView.play() + } } diff --git a/Projects/Modules/DesignSystem/Src/UIComponent/Cell/TagCollectionViewCell.swift b/Projects/Modules/DesignSystem/Src/UIComponent/Cell/TagCollectionViewCell.swift index 474f7af7..2a991328 100644 --- a/Projects/Modules/DesignSystem/Src/UIComponent/Cell/TagCollectionViewCell.swift +++ b/Projects/Modules/DesignSystem/Src/UIComponent/Cell/TagCollectionViewCell.swift @@ -9,7 +9,7 @@ import UIKit //import LikeInterface -final class TagCollectionViewCell: UICollectionViewCell { +public final class TagCollectionViewCell: UICollectionViewCell { private lazy var stackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal @@ -64,7 +64,7 @@ final class TagCollectionViewCell: UICollectionViewCell { } } // - override func layoutSubviews() { + public override func layoutSubviews() { super.layoutSubviews() setUpLayer() } @@ -74,34 +74,22 @@ final class TagCollectionViewCell: UICollectionViewCell { contentView.layer.masksToBounds = true } - func bind(_ viewModel: TagItemViewModel) { + public func bind(_ viewModel: TagItemViewModel) { self.titleLabel.text = viewModel.title self.emojiView.text = viewModel.emoji } } -struct TagItemViewModel { - let emojiCode: String - let title: String +public struct TagItemViewModel { + public let emojiCode: String + public let title: String - var emoji: String { + public var emoji: String { emojiCode.unicodeToEmoji() } - init(emojiCode: String, title: String) { + public init(emojiCode: String, title: String) { self.emojiCode = emojiCode self.title = title } } -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -//struct TagCellPreview: PreviewProvider { -//// static var previews: some View { -//// PreviewRepresentable { -//// -//// }.frame(width: 200, height: 150) -//// .previewLayout(.sizeThatFits) -//// } -//} -#endif diff --git a/Projects/Modules/DesignSystem/Src/UIComponent/TagCollectionView.swift b/Projects/Modules/DesignSystem/Src/UIComponent/TagCollectionView.swift index f30c1edf..e51d78c9 100644 --- a/Projects/Modules/DesignSystem/Src/UIComponent/TagCollectionView.swift +++ b/Projects/Modules/DesignSystem/Src/UIComponent/TagCollectionView.swift @@ -88,14 +88,24 @@ extension TagCollectionView: UICollectionViewDataSource { } } -class LeftAlignCollectionViewFlowLayout: UICollectionViewFlowLayout { +public class LeftAlignCollectionViewFlowLayout: UICollectionViewFlowLayout { let cellSpacing: CGFloat = 10 - - override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let sidePadding: CGFloat + + public init(sidePadding: CGFloat = 10) { + self.sidePadding = sidePadding + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { self.minimumLineSpacing = 10.0 - sectionInset = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + sectionInset = UIEdgeInsets(top: 0, left: sidePadding, bottom: 0, right: sidePadding) let attributes = super.layoutAttributesForElements(in: rect) diff --git a/Projects/Modules/DesignSystem/Src/Util/Emoji+Util.swift b/Projects/Modules/DesignSystem/Src/Util/Emoji+Util.swift index 24408684..30e76f2c 100644 --- a/Projects/Modules/DesignSystem/Src/Util/Emoji+Util.swift +++ b/Projects/Modules/DesignSystem/Src/Util/Emoji+Util.swift @@ -9,7 +9,7 @@ import Foundation public extension String { func unicodeToEmoji() -> String { - guard let hex = Int(self, radix: 16), + guard let hex = Int(self.dropFirst(2), radix: 16), let scalar = UnicodeScalar(hex) else { return "🧩" diff --git a/Projects/Modules/DesignSystem/Src/Util/Preview+UIView.swift b/Projects/Modules/DesignSystem/Src/Util/Preview+UIView.swift index 27f8eea2..a4f271d4 100644 --- a/Projects/Modules/DesignSystem/Src/Util/Preview+UIView.swift +++ b/Projects/Modules/DesignSystem/Src/Util/Preview+UIView.swift @@ -24,6 +24,4 @@ public struct UIViewPreview: UIViewRepresentable { view.setContentHuggingPriority(.defaultHigh, for: .vertical) } } - - #endif diff --git a/Projects/Modules/DesignSystem/Src/Util/UICollectionView+Utils.swift b/Projects/Modules/DesignSystem/Src/Util/UICollectionView+Utils.swift index be50921c..4c994237 100644 --- a/Projects/Modules/DesignSystem/Src/Util/UICollectionView+Utils.swift +++ b/Projects/Modules/DesignSystem/Src/Util/UICollectionView+Utils.swift @@ -33,3 +33,13 @@ public extension UICollectionView { return reusableView } } + +extension Reactive where Base: UICollectionView { + public func items + (cellType: Cell.Type = Cell.self) + -> (_ source: Source) + -> (_ configureCell: @escaping (Int, Sequence.Element, Cell) -> Void) + -> Disposable where Source.Element == Sequence { + return self.items(cellIdentifier: Cell.reuseIdentifier, cellType: cellType) + } +} diff --git a/Projects/Modules/DesignSystem/Src/Util/UITableView+Utils.swift b/Projects/Modules/DesignSystem/Src/Util/UITableView+Utils.swift index 73ad644d..c0fa5a18 100644 --- a/Projects/Modules/DesignSystem/Src/Util/UITableView+Utils.swift +++ b/Projects/Modules/DesignSystem/Src/Util/UITableView+Utils.swift @@ -32,3 +32,13 @@ public extension UITableView { return cell } } + +extension Reactive where Base: UITableView { + public func items + (cellType: Cell.Type = Cell.self) + -> (_ source: Source) + -> (_ configureCell: @escaping (Int, Sequence.Element, Cell) -> Void) + -> Disposable where Source.Element == Sequence { + return self.items(cellIdentifier: Cell.reuseIdentifier, cellType: cellType) + } +} diff --git a/Projects/Modules/DesignSystem/Src/WebView/TFWebViewController.swift b/Projects/Modules/DesignSystem/Src/WebView/TFWebViewController.swift new file mode 100644 index 00000000..72bed884 --- /dev/null +++ b/Projects/Modules/DesignSystem/Src/WebView/TFWebViewController.swift @@ -0,0 +1,87 @@ +// +// TFWebViewController.swift +// DSKit +// +// Created by Kanghos on 5/30/24. +// + +import UIKit +import WebKit + +import Core + +public class TFWebViewController: TFBaseViewController { + + // MARK: - Properties + private var webView: WKWebView? + private let indicator = UIActivityIndicatorView(style: .medium) + private let url: URL + // MARK: - Lifecycle + + private let closeButton: UIBarButtonItem = .exit + + public init(title: String? = nil, url: URL) { + self.url = url + super.init(nibName: nil, bundle: nil) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func makeUI() { + view.backgroundColor = .white + setAttributes() + setContraints() + + self.navigationItem.rightBarButtonItem = closeButton + } + + private func setAttributes() { + + let configuration = WKWebViewConfiguration() + + webView = WKWebView(frame: .zero, configuration: configuration) + self.webView?.navigationDelegate = self + + guard + let webView = webView + else { return } + let request = URLRequest(url: url) + webView.load(request) + indicator.startAnimating() + } + + private func setContraints() { + guard let webView = webView else { return } + view.addSubview(webView) + view.backgroundColor = .clear + webView.addSubview(indicator) + + webView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + indicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + + } + + public override func bindViewModel() { + closeButton.rx.tap + .subscribe(onNext: { [weak self] in + self?.dismiss(animated: true, completion: nil) + }).disposed(by: disposeBag) + } +} + +extension TFWebViewController: WKNavigationDelegate { + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + indicator.startAnimating() + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + indicator.stopAnimating() + } +} diff --git a/Projects/Modules/Network/Src/Network/BaseTargetType.swift b/Projects/Modules/Network/Src/Network/BaseTargetType.swift index 0ebe9c3d..599dedff 100644 --- a/Projects/Modules/Network/Src/Network/BaseTargetType.swift +++ b/Projects/Modules/Network/Src/Network/BaseTargetType.swift @@ -7,16 +7,20 @@ import Foundation import Moya +import Core public protocol BaseTargetType: TargetType { } public extension BaseTargetType { var baseURL: URL { - return URL(string: "http://tht-talk.store/")! + return URL(string: "http://tht-talk.co.kr/")! } var headers: [String: String]? { - return nil + return [:] + } +// return nil + // if let accessToken = Keychain.shared.get(.accessToken) { // return [ // "Authorization": "Bearer \(accessToken)", @@ -25,6 +29,8 @@ public extension BaseTargetType { // } else { // return nil // } + var validationType: ValidationType { + return.customCodes(Array(200..<500).filter { $0 != 401 }) } } diff --git a/Projects/Modules/Network/Src/Network/ProviderProtocol.swift b/Projects/Modules/Network/Src/Network/ProviderProtocol.swift index b3cd452d..11bed986 100644 --- a/Projects/Modules/Network/Src/Network/ProviderProtocol.swift +++ b/Projects/Modules/Network/Src/Network/ProviderProtocol.swift @@ -13,7 +13,6 @@ import RxSwift public protocol ProviderProtocol: AnyObject, Networkable { var provider: MoyaProvider { get set } - init(isStub: Bool, sampleStatusCode: Int, customEndpointClosure: ((Target) -> Endpoint)?) } public extension ProviderProtocol { @@ -47,6 +46,35 @@ public extension ProviderProtocol { func request(type: D.Type, target: Target) -> Single { provider.rx.request(target) .map(type) + .catch { error in + if let error = error as? MoyaError { + print(error.localizedDescription) + return .error(error) + } + if let error = error as? DecodingError { + print(error.localizedDescription) + return .error(error) + } + print(error.localizedDescription) + return .error(error) + } + } + + func request(target: Target, completion: @escaping (Result) -> Void) { + provider.request(target) { result in + switch result { + case let .success(response): + let decoder = JSONDecoder() + do { + let model = try decoder.decode(D.self, from: response.data) + completion(.success(model)) + } catch { + completion(.failure(error)) + } + case let .failure(error): + completion(.failure(error)) + } + } } func requestWithNoContent(target: Target) -> Single { diff --git a/Tuist/ProjectDescriptionHelpers/Target+Templates.swift b/Tuist/ProjectDescriptionHelpers/Target+Templates.swift index b1d9df06..9076faac 100644 --- a/Tuist/ProjectDescriptionHelpers/Target+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/Target+Templates.swift @@ -38,7 +38,7 @@ public extension Target { product: .app, bundleId: makeBundleID(with: "app"), deploymentTarget: basicDeployment, - infoPlist: .extendingDefault(with: infoPlistExtension), + infoPlist: .extendingDefault(with: infoPlistExtension(name: name)), sources: sources, resources: [.glob(pattern: .relativeToRoot("Projects/App/Resources/**"))], dependencies: dependencies