diff --git a/project/Makefile b/project/Makefile index 8ebb4a01..515d403c 100644 --- a/project/Makefile +++ b/project/Makefile @@ -1,11 +1,19 @@ -# Module creation + +# Test test: TUIST_ROOT_DIR=${PWD} tuist test +# Project generation + generate: TUIST_ROOT_DIR=${PWD} tuist generate +genModule: + TUIST_ROOT_DIR=${PWD} tuist generate ${name} + +# Module generation + USER_NAME = $(shell python3 Scaffold/Scripts/author_name.py) CURRENT_DATE = $(shell pipenv run python Scaffold/Scripts/current_date.py) diff --git a/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/SplashFeatureDependency.swift b/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/SplashFeatureDependency.swift new file mode 100644 index 00000000..d8f0467e --- /dev/null +++ b/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/SplashFeatureDependency.swift @@ -0,0 +1,13 @@ +// +// SplashFeatureDependency.swift +// DependencyPlugin +// +// Created by 최준영 on 6/21/24. +// + +import ProjectDescription + +public extension ModuleDependency.Presentation { + + static let SplashFeature: TargetDependency = .project(target: "SplashFeature", path: .relativeToRoot("Projects/Presentation/Feature/Splash")) +} diff --git a/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift b/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift index 5c7e3785..46b18e56 100644 --- a/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift +++ b/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift @@ -25,14 +25,6 @@ class RootCoordinator { func start() { navigationController.setNavigationBarHidden(true, animated: false) - - // Root VC - let vc = InitialScreenVC() - let vm = InitialScreenVM(coordinator: self) - - vc.bind(viewModel: vm) - - navigationController.pushViewController(vc, animated: false) } func popViewController() { diff --git a/project/Projects/App/Sources/SceneDelegate.swift b/project/Projects/App/Sources/SceneDelegate.swift index 17ae95e9..7474990c 100644 --- a/project/Projects/App/Sources/SceneDelegate.swift +++ b/project/Projects/App/Sources/SceneDelegate.swift @@ -8,13 +8,20 @@ import UIKit import PresentationCore import Core +import RootFeature +import BaseFeature class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? // RootCoordinator - var rootCoordinator: RootCoordinator! + let router: Router = .init() + + lazy var appCoordinator: AppCoordinator = { + let coodinator = AppCoordinator(router: router) + return coodinator + }() // FCMService var fcmService: FCMService! @@ -24,8 +31,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = scene as? UIWindowScene else { return } window = UIWindow(windowScene: windowScene) + window?.makeKeyAndVisible() - let rootNavigationController = UINavigationController() let injector = DependencyInjector.shared injector @@ -37,13 +44,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // FCMService fcmService = FCMService() - - // RootCoordinator - rootCoordinator = RootCoordinator(navigationController: rootNavigationController) - - rootCoordinator?.start() - - window?.rootViewController = rootNavigationController - window?.makeKeyAndVisible() + + // Start AppCoodinator + appCoordinator.start() } } diff --git a/project/Projects/Core/Sources/RxSwift+Extension/ObservableType+Extension.swift b/project/Projects/Core/Sources/RxSwift+Extension/ObservableType+Extension.swift new file mode 100644 index 00000000..8b531fa6 --- /dev/null +++ b/project/Projects/Core/Sources/RxSwift+Extension/ObservableType+Extension.swift @@ -0,0 +1,82 @@ +// +// ObservableType+Extension.swift +// Core +// +// Created by choijunios on 10/1/24. +// + +import Foundation + + +import RxSwift + +public extension ObservableType { + + func unretained(_ object: T) -> Observable<(T, Element)> { + + self + .compactMap { [weak object] output -> (T, Element)? in + + guard let object else { return nil } + + return (object, output) + } + .asObservable() + } + + func mapToVoid() -> Observable { + + self.map { _ in () } + } +} + +public extension ObservableType where Element == Bool { + + func onSuccess() -> Observable { + + self + .filter { $0 } + .mapToVoid() + } + + func onSuccess(transform: @escaping () throws -> T) -> Observable { + + self + .filter { $0 } + .mapToVoid() + .map(transform) + .asObservable() + } + + func onSuccess(onNext: @escaping () -> Void) -> Disposable { + + self + .filter { $0 } + .mapToVoid() + .subscribe(onNext: onNext) + } + + func onFailure() -> Observable { + + self + .filter { !$0 } + .mapToVoid() + } + + func onFailure(transform: @escaping () throws -> T) -> Observable { + + self + .filter { !$0 } + .mapToVoid() + .map(transform) + .asObservable() + } + + func onFailure(onNext: @escaping () -> Void) -> Disposable { + + self + .filter { !$0 } + .mapToVoid() + .subscribe(onNext: onNext) + } +} diff --git a/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift b/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift index acb0d103..25c39eb8 100644 --- a/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift +++ b/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift @@ -63,13 +63,20 @@ public class DefaultIdleAlertVM: IdleAlertViewModelable { public class IdleAlertObject { + public struct Action { + let name: String + let action: (() -> ())? + + public init(name: String, action: (() -> Void)? = nil) { + self.name = name + self.action = action + } + } + public private(set) var title: String = "" public private(set) var description: String = "" - public private(set) var acceptButtonLabelText: String = "" - public private(set) var cancelButtonLabelText: String = "" - - public var acceptButtonClicked: PublishRelay = .init() - public var cancelButtonClicked: PublishRelay = .init() + public private(set) var acceptAction: Action = .init(name: "", action: nil) + public private(set) var cancelAction: Action = .init(name: "", action: nil) public init() { } @@ -83,13 +90,13 @@ public class IdleAlertObject { return self } - public func setAcceptButtonLabelText(_ text: String) -> Self { - self.acceptButtonLabelText = text + public func setAcceptAction(_ action: Action) -> Self { + self.acceptAction = action return self } - public func setCancelButtonLabelText(_ text: String) -> Self { - self.cancelButtonLabelText = text + public func setCancelAction(_ action: Action) -> Self { + self.cancelAction = action return self } } @@ -237,8 +244,8 @@ public class IdleBigAlertController: UIViewController { descriptionLabel.textString = object.description descriptionLabel.textAlignment = .center - acceptButton.label.textString = object.acceptButtonLabelText - cancelButton.label.textString = object.cancelButtonLabelText + acceptButton.label.textString = object.acceptAction.name + cancelButton.label.textString = object.cancelAction.name Observable.merge( @@ -255,11 +262,17 @@ public class IdleBigAlertController: UIViewController { .disposed(by: disposeBag) acceptButton.rx.tap - .bind(to: object.acceptButtonClicked) + .subscribe(onNext: { [object] _ in + + object.acceptAction.action?() + }) .disposed(by: disposeBag) cancelButton.rx.tap - .bind(to: object.cancelButtonClicked) + .subscribe(onNext: { [object] _ in + + object.cancelAction.action?() + }) .disposed(by: disposeBag) } diff --git a/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift b/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift index 7e4d470e..e905add0 100644 --- a/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift +++ b/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift @@ -10,19 +10,100 @@ import BaseFeature class SceneDelegate: UIResponder, UIWindowSceneDelegate { + let navigationController: UINavigationController = .init() + var window: UIWindow? + var router: Router! + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + window?.makeKeyAndVisible() - let vm = BaseViewModel() - let vc = ViewController() - vc.bind(viewModel: vm) + var rootVC: UIViewController! - window = UIWindow(windowScene: windowScene) - window?.rootViewController = vc + navigationController.setNavigationBarHidden(true, animated: false) + self.router = Router() + router.setRootModuleTo(module: .createRand()) - window?.makeKeyAndVisible() + DispatchQueue.main.asyncAfter(deadline: .now()+3) { + + rootVC = .createRand() + + self.router.replaceRootModuleTo(module: rootVC, animated: true) { + + print("루트 변경 완료") + } + } + + DispatchQueue.main.asyncAfter(deadline: .now()+4) { + + self.router.push( + module: .createRand(), + animated: true) { + print("첫번째 푸쉬 팝") + } + } + + DispatchQueue.main.asyncAfter(deadline: .now()+5) { + + self.router.push( + module: .createRand(), + animated: true) { + print("두번째 푸쉬 팝") + } + } + + DispatchQueue.main.asyncAfter(deadline: .now()+6) { + + self.router.popModule(animated: true) + } + + DispatchQueue.main.asyncAfter(deadline: .now()+7) { + + self.router.popModule(animated: true) + } + + DispatchQueue.main.asyncAfter(deadline: .now()+8) { + + self.router.push( + module: .createRand(), + animated: true) + } + + DispatchQueue.main.asyncAfter(deadline: .now()+9) { + + self.router.push( + module: .createRand(), + animated: true) + } + + DispatchQueue.main.asyncAfter(deadline: .now()+10) { + + self.router.popTo(module: rootVC, animated: true) + } + } +} + +extension UIViewController { + + static func createRand() -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = UIColor.randomColor() + return vc + } +} + +extension UIColor { + static func randomColor() -> UIColor { + return UIColor( + red: CGFloat.random(in: 0...1), + green: CGFloat.random(in: 0...1), + blue: CGFloat.random(in: 0...1), + alpha: 1.0 + ) } } diff --git a/project/Projects/Presentation/Feature/Base/Sources/Coordinator/BaseCoordinator.swift b/project/Projects/Presentation/Feature/Base/Sources/Coordinator/BaseCoordinator.swift new file mode 100644 index 00000000..887356b4 --- /dev/null +++ b/project/Projects/Presentation/Feature/Base/Sources/Coordinator/BaseCoordinator.swift @@ -0,0 +1,40 @@ +// +// AppCoordinator.swift +// RootFeature +// +// Created by choijunios on 10/1/24. +// + +import Foundation + + +public protocol Coordinator2: AnyObject { + + /// Coordinator를 시작한다. + func start() +} + + +/// 자식 코디네이터를 가질 수 있는 코디네이터 +open class BaseCoordinator: Coordinator2 { + + var children: [Coordinator2] = [] + + + public init(children: [Coordinator2] = []) { + self.children = children + } + + open func start() { } + + public func addChild(_ coordinator: Coordinator2) { + + children.append(coordinator) + } + + public func removeChild(_ coordinator: Coordinator2) { + + children.removeAll { $0 === coordinator} + } +} + diff --git a/project/Projects/Presentation/Feature/Base/Sources/Model/DefaultAlertObject.swift b/project/Projects/Presentation/Feature/Base/Sources/Model/DefaultAlertObject.swift new file mode 100644 index 00000000..08eeb10b --- /dev/null +++ b/project/Projects/Presentation/Feature/Base/Sources/Model/DefaultAlertObject.swift @@ -0,0 +1,45 @@ +// +// DefaultAlertObject.swift +// BaseFeature +// +// Created by choijunios on 10/2/24. +// + +import Foundation + +public final class DefaultAlertObject { + + public struct Action { + let titleName: String + let action: (() -> ())? + + public init(titleName: String, action: (() -> Void)?) { + self.titleName = titleName + self.action = action + } + } + + public var title: String = "" + public var description: String = "" + public var actions: [Action] = [] + + public init() { } + + @discardableResult + public func setTitle(_ text: String) -> Self { + self.title = text + return self + } + + @discardableResult + public func setDescription(_ text: String) -> Self { + self.description = text + return self + } + + @discardableResult + public func addAction(_ action: Action) -> Self { + actions.append(action) + return self + } +} diff --git a/project/Projects/Presentation/Feature/Base/Sources/Router/Router.swift b/project/Projects/Presentation/Feature/Base/Sources/Router/Router.swift new file mode 100644 index 00000000..2488dd0c --- /dev/null +++ b/project/Projects/Presentation/Feature/Base/Sources/Router/Router.swift @@ -0,0 +1,236 @@ +// +// Router.swift +// BaseFeature +// +// Created by choijunios on 9/30/24. +// + +import UIKit +import Domain +import DSKit + +public protocol RouterProtocol { + + typealias Module = UIViewController + typealias RoutingCompletion = () -> Void + + // MARK: present modal module + func present(_ module: Module, animated: Bool, modalPresentationSytle: UIModalPresentationStyle, completion: RoutingCompletion?) + + + + // MARK: dismiss module + func dismissModule(animated: Bool, completion: (() -> Void)?) + + + + // MARK: push module + // pop시 호출할 클로저를 여기서 지정(항상 최상단 VC가 팝되지 않음으로) + func push(module: Module, animated: Bool, popCompletion: (() -> Void)?) + + + + // MARK: pop module + func popModule(animated: Bool) + + /// 특정 모듈까지 네비게이션 스택을 비움 + func popTo(module: Module, animated: Bool) + + + + // MARK: Set root module + /// RootController의 루트 Module을 변경 + func changeRootModuleTo(module: Module, animated: Bool) + + + // MARK: change window + /// 키 윈도우의 rootController를 변경, 페인드 인/아웃 애니메이션 적용됨 + func replaceRootModuleTo(module: Module, animated: Bool, completion: RoutingCompletion?) + + + /// RootController를 생성및 KeyWindow의 루트로 지정 + func setRootModuleTo(module: Module) + + + /// Default alert를 표출 + func presentDefaultAlertController(object: DefaultAlertObject) + + + /// Default alert를 표출 + func presentIdleAlertController(type: IdleBigAlertController.ButtonType, object: IdleAlertObject) +} + +public final class Router: NSObject, RouterProtocol { + + weak var rootController: UINavigationController? + + var completion: [UnsafeRawPointer: RoutingCompletion] = [:] + + var transition: UIViewControllerAnimatedTransitioning? + + /// 네비게이션 최상단ViewController + var topViewController: UIViewController? { + rootController?.topViewController + } + + public override init() { + super.init() + } + + public func present(_ module: Module, animated: Bool, modalPresentationSytle: UIModalPresentationStyle, completion: RoutingCompletion? = nil) { + + rootController?.modalPresentationStyle = modalPresentationSytle + rootController?.present( + module, + animated: animated, + completion: completion + ) + } + + public func dismissModule(animated: Bool, completion: (() -> Void)? = nil) { + + rootController?.dismiss( + animated: animated, + completion: completion + ) + } + + public func push(module: Module, animated: Bool, popCompletion: (() -> Void)? = nil) { + + guard (module is UINavigationController) == false else { + // 디버그 모드시 런타임에러발생시킴 + return assertionFailure("\(#function) 네비게이션 컨트롤러 삽입 불가") + } + + completion[module.getRawPointer] = popCompletion + + rootController?.pushViewController( + module, + animated: animated + ) + } + + public func popModule(animated: Bool) { + + if let module = rootController?.popViewController(animated: animated) { + + let pointer = module.getRawPointer + completion[pointer]?() + completion.removeValue(forKey: pointer) + } + } + + public func popTo(module: Module, animated: Bool) { + + guard let controllers = rootController?.viewControllers else { return } + + for controller in controllers { + + if controller === module { + + rootController?.popToViewController(controller, animated: true) + } + } + } + + public func changeRootModuleTo(module: Module, animated: Bool) { + + rootController?.setViewControllers([module], animated: animated) + } + + public func replaceRootModuleTo(module: Module, animated: Bool, completion: (() -> Void)? = nil) { + + guard let keyWindow = UIWindow.keyWindow else { return } + + let navigationController = UINavigationController(rootViewController: module) + + self.rootController = navigationController + + if !animated { + // 애니메이션이 없는 경우 + setRootModuleTo(module: module) + completion?() + + return + } + + if let snapshot = keyWindow.snapshotView(afterScreenUpdates: true) { + + module.view.addSubview(snapshot) + keyWindow.rootViewController = navigationController + + completion?() + + UIView.animate(withDuration: 0.35, animations: { + snapshot.layer.opacity = 0 + }, completion: { _ in + snapshot.removeFromSuperview() + }) + } + } + + public func setRootModuleTo(module: Module) { + guard let keyWindow = UIWindow.keyWindow else { return } + let navigationController = UINavigationController(rootViewController: module) + + keyWindow.rootViewController = navigationController + + self.rootController = navigationController + } + + public func presentDefaultAlertController(object: DefaultAlertObject) { + + let alertController = UIAlertController( + title: object.title, + message: object.description, + preferredStyle: .alert + ) + + for action in object.actions { + + let alertAction = UIAlertAction(title: action.titleName, style: .default) { _ in + + // on dismiss + action.action?() + } + + alertController.addAction(alertAction) + } + + self.present( + alertController, + animated: true, + modalPresentationSytle: .automatic + ) + } + + public func presentIdleAlertController(type: IdleBigAlertController.ButtonType, object: DSKit.IdleAlertObject) { + + let alertVC = IdleBigAlertController(type: type) + alertVC.bindObject(object) + alertVC.modalPresentationStyle = .custom + self.present(alertVC, animated: true, modalPresentationSytle: .custom) + } +} + +extension UIWindow { + + static var keyWindow: UIWindow? { + + if let keyWindow = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow }) { + + return keyWindow + } + return nil + } +} + +extension UIViewController { + + var getRawPointer: UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() + } +} diff --git a/project/Projects/Presentation/Feature/Center/Sources/Screen/CenterCertificate/CenterCertificateIntroVM.swift b/project/Projects/Presentation/Feature/Center/Sources/Screen/CenterCertificate/CenterCertificateIntroVM.swift index c653acd7..4d1ea6d7 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/Screen/CenterCertificate/CenterCertificateIntroVM.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/Screen/CenterCertificate/CenterCertificateIntroVM.swift @@ -40,17 +40,18 @@ public class CenterCertificateIntroVM: BaseViewModel { // MARK: 로그아웃 logoutButtonClicked - .subscribe(onNext: { [weak self] in + .subscribe(onNext: { + [weak self] in guard let self else { return } let object = IdleAlertObject() .setTitle("로그아웃하시겠어요?") - .setAcceptButtonLabelText("로그아웃") - .setCancelButtonLabelText("취소하기") - - object - .acceptButtonClicked - .bind(to: signOutButtonComfirmed) - .disposed(by: disposeBag) + .setAcceptAction(.init( + name: "로그아웃", + action: { [signOutButtonComfirmed] in + signOutButtonComfirmed.onNext(()) + } + )) + .setCancelAction(.init(name: "취소하기")) alertObject.onNext(object) }) diff --git a/project/Projects/Presentation/Feature/Root/Project.swift b/project/Projects/Presentation/Feature/Root/Project.swift index cd46b90e..540466f2 100644 --- a/project/Projects/Presentation/Feature/Root/Project.swift +++ b/project/Projects/Presentation/Feature/Root/Project.swift @@ -32,6 +32,7 @@ let project = Project( dependencies: [ // Presentation + D.Presentation.SplashFeature, D.Presentation.AuthFeature, D.Presentation.WorkerFeature, D.Presentation.CenterFeature, @@ -39,9 +40,6 @@ let project = Project( // ThirParty D.ThirdParty.Amplitude, - D.ThirdParty.FirebaseRemoteConfig, - D.ThirdParty.FirebaseCrashlytics, - D.ThirdParty.FirebaseAnalytics, D.ThirdParty.FirebaseMessaging, ], settings: .settings( diff --git a/project/Projects/Presentation/Feature/Root/Sources/Application/AppCoordinator.swift b/project/Projects/Presentation/Feature/Root/Sources/Application/AppCoordinator.swift new file mode 100644 index 00000000..d6be2df2 --- /dev/null +++ b/project/Projects/Presentation/Feature/Root/Sources/Application/AppCoordinator.swift @@ -0,0 +1,87 @@ +// +// AppCoordinator.swift +// RootFeature +// +// Created by choijunios on 10/2/24. +// + +import Foundation +import BaseFeature +import SplashFeature +import Core + + +public class AppCoordinator: BaseCoordinator { + + let router: Router + + public init(router: Router) { + self.router = router + } + + public override func start() { + + runSplashFlow() + } +} + +extension AppCoordinator { + + /// SplashFlow를 시작합니다. + @discardableResult + func runSplashFlow() -> SplashCoordinator { + + let coordinator = SplashCoordinator(router: router) + + coordinator.startFlow = { [weak self] destination in + + guard let self else { return } + + switch destination { + case .authPage: + runCenterMainFlow() + case .mainPage(let userType): + runWorkerMainFlow() + case .centerCertificatePage: + runCenterCertificateFlow() + case .centerMakeProfilePage: + runCenterMakeProfileFlow() + } + } + + coordinator.start() + + + return coordinator + } + + + /// AuthFlow를 시작합니다. + func runAuthFlow() { + printIfDebug("Auth") + } + + + /// CenterCetrificateFlow를 시작합니다. + func runCenterCertificateFlow() { + printIfDebug("CenterCertificate") + } + + + /// CenterMainFlow를 시작합니다. + func runCenterMainFlow() { + printIfDebug("CenterMain") + } + + + /// WorkerMainFlow를 시작합니다. + func runWorkerMainFlow() { + printIfDebug("WorkerMain") + } + + + /// CenterMakeProfileFlow를 시작합니다. + func runCenterMakeProfileFlow() { + printIfDebug("Center make profile") + } +} diff --git a/project/Projects/Presentation/Feature/Root/Sources/Screen/Center/CheckApplicantCoordinator.swift b/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/Center/CheckApplicantCoordinator.swift similarity index 100% rename from project/Projects/Presentation/Feature/Root/Sources/Screen/Center/CheckApplicantCoordinator.swift rename to project/Projects/Presentation/Feature/Root/Sources/Screen/Common/Center/CheckApplicantCoordinator.swift diff --git a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift b/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift deleted file mode 100644 index 59df3d07..00000000 --- a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift +++ /dev/null @@ -1,386 +0,0 @@ -// -// InitialScreenVM.swift -// RootFeature -// -// Created by choijunios on 8/25/24. -// - -import UIKit -import Network - -import PresentationCore -import Domain -import BaseFeature -import DSKit -import Core - -import RxSwift -import RxCocoa -import FirebaseCrashlytics - - -public class InitialScreenVM: BaseViewModel { - - weak var coordinator: RootCoorinatable? - - // Input - let viewDidLoad: PublishRelay = .init() - let viewWillAppear: PublishRelay = .init() - - @Injected var authUseCase: AuthUseCase - @Injected var workerProfileUseCase: WorkerProfileUseCase - @Injected var centerProfileUseCase: CenterProfileUseCase - @Injected var userInfoLocalRepository: UserInfoLocalRepository - - // network monitoring - private let networkMonitor: NWPathMonitor = .init() - private let networkMonitoringQueue = DispatchQueue.global(qos: .background) - private let networtIsAvailablePublisher: PublishSubject = .init() - - public init(coordinator: RootCoorinatable?) { - - self.coordinator = coordinator - - super.init() - - // MARK: 네트워크 모니터링 시작 - let networkConnected: ReplaySubject = .create(bufferSize: 1) - - // 최초 1회 네트워크 연결이벤트 전송 - networtIsAvailablePublisher - .filter { $0 } - .take(1) - .map { _ in } - .bind(to: networkConnected) - .disposed(by: disposeBag) - - // 네트워크가 연결되지 않은 경우 재시도 하며, 재시도 실패시 같은 플로우 반복 - networtIsAvailablePublisher.filter { !$0 } - .subscribe(onNext: { [weak self] _ in - - let alertVO = DefaultAlertContentVO( - title: "인터넷 연결이 불안정해요", - message: "Wi-Fi 또는 셀룰러 데이터 연결을 확인한 후 다시 시도해 주세요.", - dismissButtonLabelText: "다시 시도하기") { [weak self] in - - DispatchQueue.main.asyncAfter(deadline: .now()+1) { [weak self] in - guard let self else { return } - - if self.networkMonitor.currentPath.status == .unsatisfied { - - self.networtIsAvailablePublisher.onNext(false) - } - } - } - - // 네트워크 연결되지 않음 - self?.alert.onNext(alertVO) - }) - .disposed(by: disposeBag) - - startNeworkMonitoring() - - - // MARK: 강제업데이트 확인 - // 네트워크 확인 -> 강제업데이트 확인 - let needsForceUpdate = networkConnected - .flatMap { _ in - RemoteConfigService.shared.fetchRemoteConfig() - } - .compactMap { $0.value } - .map { isConfigFetched in - - if !isConfigFetched { - Crashlytics.crashlytics().log("Remote Config fetch실패") - } - - guard let config = RemoteConfigService.shared.getForceUpdateInfo() else { - // ‼️ Config로딩 불가시 크래쉬 - Crashlytics.crashlytics().log("Remote Config획득 실패") - fatalError("Remote Config fetching에러") - } - - return config - } - .map { info in - - let minAppVersion = info.minVersion - - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String - - printIfDebug("앱 버전: \(appVersion) 최소앱버전: \(minAppVersion)") - - return minAppVersion > appVersion - } - .share() - - // 강제업데이트 필요 - needsForceUpdate - .filter { $0 } - .subscribe(onNext: { - [weak self] _ in - - guard let self else { return } - - // 네트워크 연결되지 않음 - let object = IdleAlertObject() - .setTitle("최신 버전의 앱이 있어요") - .setDescription("유저분들의 의견을 반영해 앱을 더 발전시켰어요.\n보다 좋은 서비스를 만나기 위해, 업데이트해주세요.") - .setAcceptButtonLabelText("앱 종료") - .setCancelButtonLabelText("앱 업데이트") - - object - .cancelButtonClicked - .subscribe(onNext: { [weak self] in - self?.openAppStoreForUpdate() - }) - .disposed(by: disposeBag) - - object - .acceptButtonClicked - .subscribe(onNext: { - exit(0) - }) - .disposed(by: disposeBag) - - alertObject.onNext(object) - }) - .disposed(by: disposeBag) - - // 강제업데이트 필요하지 않음 - let forceUpdateChecked = needsForceUpdate.filter { !$0 } - - // MARK: 유저별 플로우 시작 - // 네트워크 연결확인 -> 강제업데이트 확인 -> 유저별 플로우 시작 - Observable - .combineLatest( - // 강제업데이트 확인 완료 - forceUpdateChecked, - - // viewWillAppear - viewWillAppear - ) - .subscribe(onNext: { [weak self] _ in - - guard let self else { return } - - // 유저타입이 없는 경우 Auth로 이동 - guard let userType = userInfoLocalRepository.getUserType() else { - coordinator?.auth() - return - } - - // 유저타입별 플로우 시작 - switch userType { - case .center: - centerInitialFlow() - case .worker: - workerInitialFlow() - } - - }) - .disposed(by: disposeBag) - - - // MARK: 로그아웃, 회원탈퇴시 - NotificationCenter.default.rx.notification(.popToInitialVC) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] _ in - - guard let self else { return } - - self.coordinator?.popToRoot() - }) - .disposed(by: disposeBag) - } - - func workerInitialFlow() { - - let profileFetchResult = workerProfileUseCase - .getFreshProfile(mode: .myProfile) - .asObservable() - .share() - - let profileFetchSuccess = profileFetchResult.compactMap { $0.value } - let profileFetchFailure = profileFetchResult.compactMap { $0.error } - - profileFetchSuccess - .subscribe(onNext: { [weak self] profileVO in - - guard let self else { return } - - // 불로온 정보 로컬에 저장 - userInfoLocalRepository.updateCurrentWorkerData(vo: profileVO) - - // 요양보호사 홈으로 이동 - coordinator?.workerMain() - }) - .disposed(by: disposeBag) - - - profileFetchFailure - .subscribe(onNext: { [weak self] error in - - guard let self else { return } - - switch error { - case .tokenExpiredException: - // 토큰이 만료된 경우 - coordinator?.auth() - default: - // 토큰과 무관한 에러상황 - let alertVO = DefaultAlertContentVO( - title: "초기화면 오류", - message: error.message) { - // 비정상 종료, 어플리케이션 종료 - exit(1) - } - - // 어플리케이션 종료 이벤트 - self.alert.onNext(alertVO) - } - }) - .disposed(by: disposeBag) - } - - deinit { - networkMonitor.cancel() - } - - func startNeworkMonitoring() { - - networkMonitor.pathUpdateHandler = { [weak self] path in - - DispatchQueue.main.async { - self?.checkNetworkStatusAndPublish(status: path.status, delay: 0) - } - } - - networkMonitor.start(queue: networkMonitoringQueue) - } - - func checkNetworkStatusAndPublish(status: NWPath.Status, delay: Int) { - - switch status { - case .requiresConnection, .satisfied: - // requiresConnection는 일반적으로 즉시 연결이 가능한 상태 - networtIsAvailablePublisher.onNext(true) - networtIsAvailablePublisher.onCompleted() - networkMonitor.cancel() - return - default: - networtIsAvailablePublisher.onNext(false) - return - } - } - - func centerInitialFlow() { - - // #1. 센터 상태를 확인함과 동시에 토큰 유효성 확인 - let centerJoinStatusResult = authUseCase - .checkCenterJoinStatus() - .asObservable() - .share() - - let centerJoinStatusSuccess = centerJoinStatusResult.compactMap { $0.value } - let centerJoinStatusFailure = centerJoinStatusResult.compactMap { $0.error } - - centerJoinStatusFailure - .subscribe(onNext: { [weak self] error in - - guard let self else { return } - - switch error { - case .tokenExpiredException, .tokenNotFound: - // 토큰이 만료된 경우 - coordinator?.auth() - default: - // 토큰과 무관한 에러상황 - let alertVO = DefaultAlertContentVO( - title: "초기화면 오류", - message: error.message) { - // 어플리케이션 종료 - exit(0) - } - - // 어플리케이션 종료 이벤트 - self.alert.onNext(alertVO) - } - }) - .disposed(by: disposeBag) - - // #2. 센터 상태에 따른 분기후 프로필 확인 - let checkProfileRegisterResult = centerJoinStatusSuccess - .compactMap { [weak self] info -> CenterJoinStatusInfoVO? in - guard let self else { return nil } - - switch info.centerManagerAccountStatus { - case .approved: - return info - case .pending, .new: - - self.coordinator?.centerAuth() - - return nil - } - } - .flatMap { [centerProfileUseCase] _ in - centerProfileUseCase - .getProfile(mode: .myProfile) - } - .share() - - let profileExists = checkProfileRegisterResult.compactMap { $0.value } - let profileDoentExistOrError = checkProfileRegisterResult.compactMap { $0.error } - - profileDoentExistOrError - .subscribe(onNext: { [weak self] error in - - guard let self else { return } - - switch error { - case .centerNotFoundException: - - // 센터가 없는 경우 -> 프로필이 등록되지 않음 - // 프로필 등록화면으로 이동 - self.coordinator?.makeCenterProfile() - - return - - default: - // 토큰과 무관한 에러상황 - let alertVO = DefaultAlertContentVO( - title: "초기화면 오류", - message: error.message) { - // 어플리케이션 종료 - exit(0) - } - - // 어플리케이션 종료 이벤트 - self.alert.onNext(alertVO) - } - }) - .disposed(by: disposeBag) - - // 프로필이 존재함으로 메인화면으로 이동 - profileExists - .subscribe(onNext: { [weak self] profileVO in - guard let self else { return } - - // 불로온 정보를 로컬에 저장 - userInfoLocalRepository.updateCurrentCenterData(vo: profileVO) - - coordinator?.centerMain() - }) - .disposed(by: disposeBag) - } - - /// 앱스토에에서 해당앱을 엽니다. - func openAppStoreForUpdate() { - let url = "itms-apps://itunes.apple.com/app/6670529341"; - if let url = URL(string: url), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:]) - } - } -} - diff --git a/project/Projects/Presentation/Feature/Splash/ExampleApp/Resources/LaunchScreen.storyboard b/project/Projects/Presentation/Feature/Splash/ExampleApp/Resources/LaunchScreen.storyboard new file mode 100644 index 00000000..a2157a3e --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/ExampleApp/Resources/LaunchScreen.storyboard @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/AppDelegate.swift b/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/AppDelegate.swift new file mode 100644 index 00000000..00267bb5 --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// +// +// Created by 최준영 on 6/19/24. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + 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) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/SceneDelegate.swift b/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/SceneDelegate.swift new file mode 100644 index 00000000..015452b5 --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/SceneDelegate.swift @@ -0,0 +1,23 @@ +// +// SceneDelegate.swift +// +// +// Created by 최준영 on 6/19/24. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + + guard let windowScene = scene as? UIWindowScene else { return } + + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + } +} diff --git a/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/ViewController.swift b/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/ViewController.swift new file mode 100644 index 00000000..e439d432 --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/ExampleApp/Sources/ViewController.swift @@ -0,0 +1,29 @@ +// +// ViewController.swift +// +// +// Created by 최준영 on 6/19/24. +// + +import UIKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + + let initialLabel = UILabel() + + initialLabel.text = "Example app" + + view.backgroundColor = .white + + view.addSubview(initialLabel) + initialLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + initialLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + initialLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} + diff --git a/project/Projects/Presentation/Feature/Splash/Project.swift b/project/Projects/Presentation/Feature/Splash/Project.swift new file mode 100644 index 00000000..c4854f4d --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/Project.swift @@ -0,0 +1,76 @@ +// +// Project.swift +// ProjectDescriptionHelpers +// +// Created by choijunios on 2024/10/01 +// + +import ProjectDescription +import ProjectDescriptionHelpers +import ConfigurationPlugin +import DependencyPlugin + +let project = Project( + name: "Splash", + settings: .settings( + configurations: IdleConfiguration.emptyConfigurations + ), + targets: [ + + /// FeatureConcrete + .target( + name: "SplashFeature", + destinations: DeploymentSettings.platforms, + product: .staticFramework, + bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)", + deploymentTargets: DeploymentSettings.deployment_version, + sources: ["Sources/**"], + resources: ["Resources/**"], + dependencies: [ + // Presentation + D.Presentation.BaseFeature, + + // ThirdParty + D.ThirdParty.FirebaseRemoteConfig, + D.ThirdParty.FirebaseCrashlytics, + ], + settings: .settings( + configurations: IdleConfiguration.presentationConfigurations + ) + ), + + /// FeatureConcrete ExampleApp + .target( + name: "Splash_ExampleApp", + destinations: DeploymentSettings.platforms, + product: .app, + bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)", + deploymentTargets: DeploymentSettings.deployment_version, + infoPlist: IdleInfoPlist.exampleAppDefault, + sources: ["ExampleApp/Sources/**"], + resources: ["ExampleApp/Resources/**"], + dependencies: [ + .target(name: "SplashFeature"), + ], + settings: .settings( + configurations: IdleConfiguration.presentationConfigurations + ) + ), + ], + schemes: [ + Scheme.makeSchemes( + .target("SplashFeature"), + configNames: [ + IdleConfiguration.debugConfigName, + IdleConfiguration.releaseConfigName + ] + ), + Scheme.makeSchemes( + .target("Splash_ExampleApp"), + configNames: [ + IdleConfiguration.debugConfigName, + IdleConfiguration.releaseConfigName + ] + ) + ].flatMap { $0 } +) diff --git a/project/Projects/Presentation/Feature/Splash/Resources/Empty.md b/project/Projects/Presentation/Feature/Splash/Resources/Empty.md new file mode 100644 index 00000000..64e53d46 --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/Resources/Empty.md @@ -0,0 +1,2 @@ +# <#Title#> + diff --git a/project/Projects/Presentation/Feature/Root/Sources/RemotConfig/RemoteConfigService.swift b/project/Projects/Presentation/Feature/Splash/Sources/RemotConfig/RemoteConfigService.swift similarity index 96% rename from project/Projects/Presentation/Feature/Root/Sources/RemotConfig/RemoteConfigService.swift rename to project/Projects/Presentation/Feature/Splash/Sources/RemotConfig/RemoteConfigService.swift index 540ce156..08c827d7 100644 --- a/project/Projects/Presentation/Feature/Root/Sources/RemotConfig/RemoteConfigService.swift +++ b/project/Projects/Presentation/Feature/Splash/Sources/RemotConfig/RemoteConfigService.swift @@ -1,6 +1,6 @@ // -// asd.swift -// RootFeature +// RemoteConfigService.swift +// SplashFeature // // Created by choijunios on 9/29/24. // diff --git a/project/Projects/Presentation/Feature/Splash/Sources/SplashCoordinator.swift b/project/Projects/Presentation/Feature/Splash/Sources/SplashCoordinator.swift new file mode 100644 index 00000000..8770a844 --- /dev/null +++ b/project/Projects/Presentation/Feature/Splash/Sources/SplashCoordinator.swift @@ -0,0 +1,431 @@ +// +// SplashCoordinator.swift +// SplashFeature +// +// Created by choijunios on 10/1/24. +// + +import UIKit +import Network +import BaseFeature +import DSKit +import Domain +import Core + + +import RxSwift +import FirebaseCrashlytics +import FirebaseRemoteConfig + +public enum SplashCoordinatorDestination { + case authPage + case mainPage(userType: UserType) + case centerCertificatePage + case centerMakeProfilePage +} + +public class SplashCoordinator: BaseCoordinator { + + // DI + @Injected var authUseCase: AuthUseCase + @Injected var workerProfileUseCase: WorkerProfileUseCase + @Injected var centerProfileUseCase: CenterProfileUseCase + @Injected var userInfoLocalRepository: UserInfoLocalRepository + + let router: Router + + public var startFlow: ((SplashCoordinatorDestination) -> ())! + + // #1. 네트워크 연결상태 확인 + private let networkCheckingPassed: PublishSubject = .init() + + private let networkMonitor: NWPathMonitor = .init() + private let networkMonitoringQueue = DispatchQueue.global(qos: .background) + private let networkConnectionState: PublishSubject = .init() + + // #2. 강제 업데이트 확인 + let forceUpdateCheckingPassed: PublishSubject = .init() + + // #3. 유저 인증정보 확인 + /// - 요양보호사 토큰이 유효한가? + /// - 센터토큰이 유효한가? + /// - 센터 인증정보가 있는가? + /// - 센터 프로필 정보가 있는가? + let userAuthStateCheckingPasssed: PublishSubject = .init() + + + let disposeBag = DisposeBag() + + public init(router: Router) { + self.router = router + } + + public override func start() { + + let module = SplashPageVC() + + router.setRootModuleTo( + module: module + ) + + checkNetworkFlow() + checkForceUpdateFlow() + checkUserFlow() + startNeworkMonitoring() + + Observable + .zip( + networkCheckingPassed, + forceUpdateCheckingPassed, + userAuthStateCheckingPasssed + ) + .unretained(self) + .subscribe(onNext: { (object, arg1) in + let (_, _, userType) = arg1 + object.startFlow(.mainPage(userType: userType)) + }) + .disposed(by: disposeBag) + } + + /// 딥링크 단계를 수행합니다. + /// 필수 인증 단계를 통과하지 못하면 false를 반환합니다. + public func startWithDeepLink(successCompletion: @escaping (UserType) -> ()) { + + let module = SplashPageVC() + + router.push( + module: module, + animated: false + ) + + checkNetworkFlow() + checkForceUpdateFlow() + checkUserFlow() + startNeworkMonitoring() + + Observable + .zip( + networkCheckingPassed, + forceUpdateCheckingPassed, + userAuthStateCheckingPasssed + ) + .unretained(self) + .subscribe(onNext: { (object, arg1) in + let (_, _, userType) = arg1 + + successCompletion(userType) + }) + .disposed(by: disposeBag) + } +} + +// MARK: 네트워크 확인 +private extension SplashCoordinator { + + func checkNetworkFlow() { + + // 네트워가 연결된 상태 + networkConnectionState + .onSuccess() + .take(1) + .bind(to: networkCheckingPassed) + .disposed(by: disposeBag) + + // 네트워크가 연결되지 않음 + networkConnectionState + .onFailure { [router] in + + let alertObject = DefaultAlertObject() + + alertObject + .setTitle("인터넷 연결이 불안정해요") + .setDescription("Wi-Fi 또는 셀룰러 데이터 연결을 확인한 후 다시 시도해 주세요.") + .addAction(.init( + titleName: "다시 시도하기") { + + DispatchQueue.main.asyncAfter(deadline: .now()+1) { [weak self] in + guard let self else { return } + + let status = self.networkMonitor.currentPath.status + + self.networkConnectionState + .onNext(status == .requiresConnection || + status == .satisfied + ) + } + } + ) + + router + .presentDefaultAlertController(object: alertObject) + } + .disposed(by: disposeBag) + } + + func startNeworkMonitoring() { + networkMonitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.checkNetworkStatusAndPublish(status: path.status, delay: 0) + } + } + networkMonitor.start(queue: networkMonitoringQueue) + } + + func checkNetworkStatusAndPublish(status: NWPath.Status, delay: Int) { + networkConnectionState.onNext( + status == .requiresConnection || + status == .satisfied + ) + } +} + +// MARK: 강제업데이트 유무 확인하기 +private extension SplashCoordinator { + + func checkForceUpdateFlow() { + + let passForceUpdate = networkCheckingPassed + .flatMap({ _ in + RemoteConfigService.shared.fetchRemoteConfig() + }) + .compactMap { $0.value } + .map { isConfigFetched in + + if !isConfigFetched { + Crashlytics.crashlytics().log("Remote Config fetch실패") + } + + guard let config = RemoteConfigService.shared.getForceUpdateInfo() else { + // ‼️ Config로딩 불가시 크래쉬 + Crashlytics.crashlytics().log("Remote Config획득 실패") + fatalError("Remote Config fetching에러") + } + + return config + } + .map { info in + + let minAppVersion = info.minVersion + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String + + printIfDebug("앱 버전: \(appVersion) 최소앱버전: \(minAppVersion)") + + return minAppVersion <= appVersion + } + .share() + + // 강제업데이트 필요 + passForceUpdate + .onFailure { [weak self] in + + guard let self else { return } + + // 네트워크 연결되지 않음 + let object = IdleAlertObject() + .setTitle("최신 버전의 앱이 있어요") + .setDescription("유저분들의 의견을 반영해 앱을 더 발전시켰어요.\n보다 좋은 서비스를 만나기 위해, 업데이트해주세요.") + .setAcceptAction(.init( + name: "앱 종료", + action: { exit(0) }) + ) + .setCancelAction(.init( + name: "앱 업데이트", + action: { + let url = "itms-apps://itunes.apple.com/app/6670529341"; + if let url = URL(string: url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) + } + }) + ) + + router.presentIdleAlertController( + type: .red, + object: object + ) + } + .disposed(by: disposeBag) + + // 강제업데이트 필요하지 않음 + passForceUpdate + .onSuccess() + .bind(to: forceUpdateCheckingPassed) + .disposed(by: disposeBag) + } +} + +// MARK: 로컬에 저장된 유저가 있는지 확인 +private extension SplashCoordinator { + + func checkUserFlow() { + + let seekLocalUser = forceUpdateCheckingPassed + .map { [userInfoLocalRepository] _ in + + return userInfoLocalRepository.getUserType() + } + + let userFound = seekLocalUser.compactMap({ $0 }) + let userNotFound = seekLocalUser.filter({ $0 == nil }) + + userNotFound + .mapToVoid() + .subscribe(onNext: { [weak self] in + self?.startFlow(.authPage) + }) + .disposed(by: disposeBag) + + userFound + .unretained(self) + .subscribe(onNext: { (object, userType) in + switch userType { + case .center: + object.checkCenterFlow() + case .worker: + object.checkWorkerFlow() + } + }) + .disposed(by: disposeBag) + } + + func checkWorkerFlow() { + + let profileFetchResult = workerProfileUseCase + .getFreshProfile(mode: .myProfile) + .asObservable() + .share() + + let profileFetchSuccess = profileFetchResult.compactMap { $0.value } + let profileFetchFailure = profileFetchResult.compactMap { $0.error } + + profileFetchSuccess + .unretained(self) + .map { (object, profileVO) -> UserType in + + // 불로온 정보 로컬에 저장 + object.userInfoLocalRepository.updateCurrentWorkerData(vo: profileVO) + + return .worker + } + .bind(to: userAuthStateCheckingPasssed) + .disposed(by: disposeBag) + + profileFetchFailure + .unretained(self) + .subscribe(onNext: { (object, error) in + + switch error { + case .tokenExpiredException: + // 토큰이 만료된 경우 + object.startFlow(.authPage) + default: + // 토큰과 무관한 에러상황 + let alertVO = DefaultAlertObject() + alertVO + .setTitle("초기화면 오류") + .setDescription(error.message) + .addAction(.init( + titleName: "앱 종료", + action: { exit(1) } + )) + } + }) + .disposed(by: disposeBag) + } + + func checkCenterFlow() { + + let centerJoinStatusResult = authUseCase + .checkCenterJoinStatus() + .asObservable() + .share() + + let centerJoinStatusSuccess = centerJoinStatusResult.compactMap { $0.value } + let centerJoinStatusFailure = centerJoinStatusResult.compactMap { $0.error } + + centerJoinStatusFailure + .unretained(self) + .subscribe(onNext: { (object, error) in + + switch error { + case .tokenExpiredException, .tokenNotFound: + // 토큰이 만료된 경우 + object.startFlow(.authPage) + default: + // 토큰과 무관한 에러상황 + let alertVO = DefaultAlertObject() + alertVO + .setTitle("초기화면 오류") + .setDescription(error.message) + .addAction(.init( + titleName: "앱 종료", + action: { exit(1) } + )) + } + }) + .disposed(by: disposeBag) + + let checkProfileRegisterResult = centerJoinStatusSuccess + .unretained(self) + .compactMap { (object, info) -> CenterJoinStatusInfoVO? in + + switch info.centerManagerAccountStatus { + case .approved: + return info + case .pending, .new: + + // 센터인증화면으로 이동 + object.startFlow(.authPage) + + return nil + } + } + .flatMap { [centerProfileUseCase] _ in + centerProfileUseCase + .getProfile(mode: .myProfile) + } + .share() + + let profileExists = checkProfileRegisterResult.compactMap { $0.value } + let profileDoentExistOrError = checkProfileRegisterResult.compactMap { $0.error } + + profileDoentExistOrError + .unretained(self) + .subscribe(onNext: { (object, error) in + + switch error { + case .centerNotFoundException: + + // 센터가 없는 경우 -> 프로필이 등록되지 않음 + // 프로필 등록화면으로 이동 + object.startFlow(.centerMakeProfilePage) + + default: + // 토큰과 무관한 에러상황 + let alertVO = DefaultAlertObject() + alertVO + .setTitle("초기화면 오류") + .setDescription(error.message) + .addAction(.init( + titleName: "앱 종료", + action: { exit(1) } + )) + } + }) + .disposed(by: disposeBag) + + // 프로필이 존재함으로 메인화면으로 이동 + profileExists + .unretained(self) + .map { (object, profileVO) -> UserType in + + // 불로온 정보를 로컬에 저장 + object.userInfoLocalRepository.updateCurrentCenterData(vo: profileVO) + + return .center + } + .bind(to: userAuthStateCheckingPasssed) + .disposed(by: disposeBag) + } +} + diff --git a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift b/project/Projects/Presentation/Feature/Splash/Sources/SplashPageVC.swift similarity index 65% rename from project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift rename to project/Projects/Presentation/Feature/Splash/Sources/SplashPageVC.swift index cca662c2..27afba3c 100644 --- a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift +++ b/project/Projects/Presentation/Feature/Splash/Sources/SplashPageVC.swift @@ -1,6 +1,6 @@ // -// InitialScreenVC.swift -// RootFeature +// SplashPageVC.swift +// SplashFeature // // Created by choijunios on 8/25/24. // @@ -14,7 +14,7 @@ import DSKit import RxCocoa import RxSwift -public class InitialScreenVC: BaseViewController { +public class SplashPageVC: BaseViewController { // Init @@ -40,15 +40,5 @@ public class InitialScreenVC: BaseViewController { private func setLayout() { } private func setObservable() { } - - public func bind(viewModel: InitialScreenVM) { - - super.bind(viewModel: viewModel) - - self.rx.viewDidAppear - .map({ _ in }) - .bind(to: viewModel.viewWillAppear) - .disposed(by: disposeBag) - } } diff --git a/project/Scaffold/Feature/Project.stencil b/project/Scaffold/Feature/Project.stencil index 0bb604ad..b52776ff 100644 --- a/project/Scaffold/Feature/Project.stencil +++ b/project/Scaffold/Feature/Project.stencil @@ -20,7 +20,7 @@ let project = Project( /// FeatureConcrete .target( name: "{{ projectName }}Feature", - destinations: DeploymentSettings.platform, + destinations: DeploymentSettings.platforms, product: .staticFramework, bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)", deploymentTargets: DeploymentSettings.deployment_version, @@ -28,16 +28,7 @@ let project = Project( resources: ["Resources/**"], dependencies: [ // Presentation - D.Presentation.PresentationCore, - D.Presentation.DSKit, - - // Domain - D.Domain.UseCaseInterface, - D.Domain.RepositoryInterface, - - // ThirdParty - D.ThirdParty.RxSwift, - D.ThirdParty.RxCocoa, + D.Presentation.BaseFeature, ], settings: .settings( configurations: IdleConfiguration.presentationConfigurations @@ -47,7 +38,7 @@ let project = Project( /// FeatureConcrete ExampleApp .target( name: "{{ projectName }}_ExampleApp", - destinations: DeploymentSettings.platform, + destinations: DeploymentSettings.platforms, product: .app, bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)", deploymentTargets: DeploymentSettings.deployment_version, diff --git a/project/graph.png b/project/graph.png index fb16f469..22bb83c7 100644 Binary files a/project/graph.png and b/project/graph.png differ