diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index cb017d7c..da9a3199 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -54,10 +54,22 @@ extension SceneDelegate: SplashViewDelegate { guard let userDataRepository = DIContainer.shared.resolve(type: UserDataRepositoryProtocol.self) else { fatalError("userDataRepository 의존성이 등록되지 않았습니다.") } + guard let onboardingRepository = DIContainer.shared.resolve(type: OnboardingRepositoryProtocol.self) + else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") } + Task { @MainActor in let isLogined = await userDataRepository.reissueToken() if isLogined { - window?.rootViewController = TabBarView() + if onboardingRepository.isOnboardingDone() { + window?.rootViewController = TabBarView() + } else { + guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else { + fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") + } + let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) + let navigationController = UINavigationController(rootViewController: onboardingView) + window?.rootViewController = navigationController + } } else { let introView = IntroView() let navigationController = UINavigationController(rootViewController: introView) diff --git a/Projects/DataSource/Sources/Common/Enum/UserDefaultsKey.swift b/Projects/DataSource/Sources/Common/Enum/UserDefaultsKey.swift index 8e0eb971..88178c51 100644 --- a/Projects/DataSource/Sources/Common/Enum/UserDefaultsKey.swift +++ b/Projects/DataSource/Sources/Common/Enum/UserDefaultsKey.swift @@ -8,5 +8,5 @@ enum UserDefaultsKey: String { case nickname case socialLoginType - case profileImageUrl + case onboarding } diff --git a/Projects/DataSource/Sources/Common/Error/UserError.swift b/Projects/DataSource/Sources/Common/Error/UserError.swift index 964ca8c3..f9ca67de 100644 --- a/Projects/DataSource/Sources/Common/Error/UserError.swift +++ b/Projects/DataSource/Sources/Common/Error/UserError.swift @@ -12,9 +12,9 @@ enum UserError: Error, CustomStringConvertible { case socialLoginTypeSaveFailed case socialLoginTypeLoadFailed case socialLoginTypeRemoveFailed - case profileImageUrlSaveFailed - case profileImageUrlLoadFailed - case profileImageUrlRemoveFailed + case onboardingSaveFailed + case onboardingLoadFailed + case onboardingRemoveFailed case unknown(error: Error) var description: String { @@ -31,12 +31,12 @@ enum UserError: Error, CustomStringConvertible { return "소셜 로그인 타입 불러오기에 실패했습니다." case .socialLoginTypeRemoveFailed: return "소셜 로그인 타입 삭제 실패했습니다." - case .profileImageUrlSaveFailed: - return "유저 프로필 저장 실패했습니다." - case .profileImageUrlLoadFailed: - return "유저 프로필 불러오기에 실패했습니다." - case .profileImageUrlRemoveFailed: - return "유저 프로필 삭제 실패했습니다." + case .onboardingSaveFailed: + return "온보딩 여부 저장 실패했습니다." + case .onboardingLoadFailed: + return "온보딩 여부 불러오기에 실패했습니다." + case .onboardingRemoveFailed: + return "온보딩 여부를 삭제하는데 실패했습니다." case .unknown(let error): return "알 수 없는 에러가 발생했습니다. \(error.localizedDescription)" } diff --git a/Projects/DataSource/Sources/Repository/AuthRepository.swift b/Projects/DataSource/Sources/Repository/AuthRepository.swift index 1d15ec65..74679809 100644 --- a/Projects/DataSource/Sources/Repository/AuthRepository.swift +++ b/Projects/DataSource/Sources/Repository/AuthRepository.swift @@ -138,13 +138,6 @@ final class AuthRepository: AuthRepositoryProtocol { } } - // UserDefaults에 프로필 이미지를 저장합니다. - private func saveUserProfileImageUrl(profileImageUrl: URL) throws { - guard userDefaultsStorage.save(profileImageUrl.absoluteString, forKey: UserDefaultsKey.profileImageUrl.rawValue) else { - throw UserError.profileImageUrlSaveFailed - } - } - // UserDefaults에 저장된 유저 정보(닉네임, 소셜 로그인 타입, 프로필 이미지)를 삭제합니다. private func removeUserInfo() throws { guard userDefaultsStorage.remove(forKey: UserDefaultsKey.nickname.rawValue) else { @@ -155,8 +148,9 @@ final class AuthRepository: AuthRepositoryProtocol { throw UserError.socialLoginTypeRemoveFailed } - guard userDefaultsStorage.remove(forKey: UserDefaultsKey.profileImageUrl.rawValue) else { - throw UserError.profileImageUrlRemoveFailed + guard userDefaultsStorage.remove(forKey: UserDefaultsKey.onboarding.rawValue) else { + throw UserError.onboardingRemoveFailed } + } } diff --git a/Projects/DataSource/Sources/Repository/OnboardingRepository.swift b/Projects/DataSource/Sources/Repository/OnboardingRepository.swift index b53a9c98..5a687bd2 100644 --- a/Projects/DataSource/Sources/Repository/OnboardingRepository.swift +++ b/Projects/DataSource/Sources/Repository/OnboardingRepository.swift @@ -9,16 +9,27 @@ import Domain final class OnboardingRepository: OnboardingRepositoryProtocol { private let networkService = NetworkService.shared + private let userDefaultsStorage = UserDefaultsStorage.shared func registerOnboarding(onboardingChoices: [String : String]) async throws -> [RecommendedRoutineEntity] { let endpoint = OnboardingEndpoint.registerOnboarding(choices: onboardingChoices) guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) else { return [] } + guard userDefaultsStorage.save(true, forKey: UserDefaultsKey.onboarding.rawValue) + else { throw UserError.onboardingSaveFailed } + let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) return recommendedRoutineEntity } + func isOnboardingDone() -> Bool { + guard let isOnboardingDone: Bool = userDefaultsStorage.load(forKey: UserDefaultsKey.onboarding.rawValue) + else { return false } + + return isOnboardingDone + } + func registerRecommendedRoutines(selectedRoutines: [Int]) async throws { let endpoint = OnboardingEndpoint.registerRecommendedRoutine(selectedRoutines: selectedRoutines) _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) diff --git a/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift index b0723e80..69f46cf9 100644 --- a/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift @@ -14,4 +14,8 @@ public protocol OnboardingRepositoryProtocol { /// 선택한 추천 루틴을 등록합니다. /// - Parameter selectedRoutines: 선택한 추천 루틴 ID 목록 func registerRecommendedRoutines(selectedRoutines: [Int]) async throws + + /// 온보딩 여부를 반환합니다. + /// - Returns: 온보딩 여부 + func isOnboardingDone() -> Bool } diff --git a/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift b/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift index 37cb3975..e914add0 100644 --- a/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift +++ b/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift @@ -10,13 +10,6 @@ import UIKit extension UIViewController { // MARK: - NavigationBar func configureNavigationBar(navigationStyle: NavigationBarStyle) { - let appearance = UINavigationBarAppearance() - appearance.backgroundEffect = .none - appearance.configureWithOpaqueBackground() - appearance.shadowColor = .clear - navigationController?.navigationBar.standardAppearance = appearance - navigationController?.navigationBar.scrollEdgeAppearance = appearance - switch navigationStyle { case .hidden: navigationController?.setNavigationBarHidden(true, animated: false) @@ -35,6 +28,11 @@ extension UIViewController { navigationController?.setNavigationBarHidden(false, animated: false) configureCustomBackButton() configureProgressNavigationBar(step: step, stepCount: stepCount) + + case .withPrograssBarWithoutBackButton(let step, let stepCount): + navigationController?.setNavigationBarHidden(false, animated: false) + navigationController?.navigationItem.setHidesBackButton(true, animated: false) + configureProgressNavigationBar(step: step, stepCount: stepCount) } } @@ -46,6 +44,7 @@ extension UIViewController { action: #selector(popViewController)) backButton.tintColor = .black navigationItem.leftBarButtonItem = backButton + changeNavigationBackground(color: .white) } private func configureCustomBackButton() { @@ -56,12 +55,14 @@ extension UIViewController { action: #selector(popTwoViewControllers)) backButton.tintColor = .black navigationItem.leftBarButtonItem = backButton + changeNavigationBackground(color: .white) } private func configureProgressNavigationBar(step: Int, stepCount: Int) { self.title = "" let progressView = ProgressBarView(step: step, stepCount: stepCount) navigationItem.titleView = progressView + changeNavigationBackground(color: BitnagilColor.gray99) } @objc private func popViewController() { @@ -82,6 +83,17 @@ extension UIViewController { navigationController.popToViewController(targetViewController, animated: true) } + private func changeNavigationBackground(color: UIColor?) { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = color + appearance.shadowColor = .clear + + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + navigationController?.navigationBar.compactAppearance = appearance + } + // MARK: - BottomSheet func presentCustomBottomSheet(contentViewController: UIViewController, maxHeight: CGFloat) { let bottomSheet = CustomBottomSheet(contentViewController: contentViewController, maxHeight: maxHeight) @@ -94,4 +106,5 @@ enum NavigationBarStyle { case withBackButton(title: String) case withPrograssBar(step: Int, stepCount: Int) case withPrograssBarWithCustomBackButton(step: Int, stepCount: Int) + case withPrograssBarWithoutBackButton(step: Int, stepCount: Int) } diff --git a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift index ddb39196..09514312 100644 --- a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift +++ b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift @@ -7,7 +7,7 @@ import UIKit -class BaseViewController: UIViewController { +public class BaseViewController: UIViewController { let viewModel: T init(viewModel: T) { @@ -19,7 +19,7 @@ class BaseViewController: UIViewController { fatalError("init(coder:) has not been implemented") } - override func viewDidLoad() { + public override func viewDidLoad() { super.viewDidLoad() configureAttribute() diff --git a/Projects/Presentation/Sources/Common/Protocol/ViewModel.swift b/Projects/Presentation/Sources/Common/Protocol/ViewModel.swift index 0d7f6212..d4e5f556 100644 --- a/Projects/Presentation/Sources/Common/Protocol/ViewModel.swift +++ b/Projects/Presentation/Sources/Common/Protocol/ViewModel.swift @@ -5,7 +5,7 @@ // Created by 최정인 on 6/26/25. // -protocol ViewModel { +public protocol ViewModel { associatedtype Input associatedtype Output diff --git a/Projects/Presentation/Sources/Home/View/HomeView.swift b/Projects/Presentation/Sources/Home/View/HomeView.swift index fe01844f..4d2a07e6 100644 --- a/Projects/Presentation/Sources/Home/View/HomeView.swift +++ b/Projects/Presentation/Sources/Home/View/HomeView.swift @@ -102,6 +102,7 @@ final class HomeView: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + configureNavigationBar(navigationStyle: .hidden) viewModel.action(input: .fetchEmotion) } diff --git a/Projects/Presentation/Sources/Login/View/IntroView.swift b/Projects/Presentation/Sources/Login/View/IntroView.swift index 2422c0e6..47ee98e3 100644 --- a/Projects/Presentation/Sources/Login/View/IntroView.swift +++ b/Projects/Presentation/Sources/Login/View/IntroView.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/6/25. // +import Domain import Shared import SnapKit import UIKit @@ -48,10 +49,13 @@ public final class IntroView: UIViewController { graphView.image = BitnagilGraphic.introGraphic startButton.addAction(UIAction { [weak self] _ in - guard let loginViewModel = DIContainer.shared.resolve(type: LoginViewModel.self) else { - fatalError("loginViewModel 의존성이 등록되지 않았습니다.") - } - let loginView = LoginView(viewModel: loginViewModel) + guard let onboardingRepository = DIContainer.shared.resolve(type: OnboardingRepositoryProtocol.self) + else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") } + + guard let loginViewModel = DIContainer.shared.resolve(type: LoginViewModel.self) + else { fatalError("loginViewModel 의존성이 등록되지 않았습니다.") } + + let loginView = LoginView(onboardingRepository: onboardingRepository, viewModel: loginViewModel) self?.navigationController?.pushViewController(loginView, animated: true) }, for: .touchUpInside) } diff --git a/Projects/Presentation/Sources/Login/View/LoginView.swift b/Projects/Presentation/Sources/Login/View/LoginView.swift index 6d896c52..8a8a4cd4 100644 --- a/Projects/Presentation/Sources/Login/View/LoginView.swift +++ b/Projects/Presentation/Sources/Login/View/LoginView.swift @@ -7,6 +7,7 @@ import AuthenticationServices import Combine +import Domain import Shared import SnapKit import UIKit @@ -31,8 +32,10 @@ final class LoginView: BaseViewController { private let kakaoLoginButton = SocialLoginButton(socialType: .kakao) private let appleLoginButton = SocialLoginButton(socialType: .apple) private var cancellables: Set + private let onboardingRepository: OnboardingRepositoryProtocol - override init(viewModel: LoginViewModel) { + init(onboardingRepository: OnboardingRepositoryProtocol, viewModel: LoginViewModel) { + self.onboardingRepository = onboardingRepository cancellables = [] super.init(viewModel: viewModel) } @@ -119,11 +122,18 @@ final class LoginView: BaseViewController { let agreementView = TermsAgreementView(viewModel: self.viewModel) self.navigationController?.pushViewController(agreementView, animated: true) } else { - guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else { - fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") + if onboardingRepository.isOnboardingDone() { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) { + window.rootViewController = TabBarView() + } + } else { + guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else { + fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") + } + let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) + self.navigationController?.pushViewController(onboardingView, animated: true) } - let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) - self.navigationController?.pushViewController(onboardingView, animated: true) } } .store(in: &cancellables) diff --git a/Projects/Presentation/Sources/MyPage/View/MypageView.swift b/Projects/Presentation/Sources/MyPage/View/MypageView.swift index 0e47fb68..b44e07c0 100644 --- a/Projects/Presentation/Sources/MyPage/View/MypageView.swift +++ b/Projects/Presentation/Sources/MyPage/View/MypageView.swift @@ -39,6 +39,18 @@ final class MypageView: BaseViewController { fatalError("init(coder:) has not been implemented") } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .white + appearance.shadowColor = .clear + + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + navigationController?.navigationBar.compactAppearance = appearance + } + override func configureAttribute() { view.backgroundColor = .white navigationItem.rightBarButtonItem = settingButton diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift index 93a26f99..eefc5fb2 100644 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift @@ -10,7 +10,7 @@ import Domain import SnapKit import UIKit -final class OnboardingView: BaseViewController { +public final class OnboardingView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 @@ -38,7 +38,7 @@ final class OnboardingView: BaseViewController { private let isFromMypage: Bool private var cancellables: Set - init( + public init( viewModel: OnboardingViewModel, onboarding: OnboardingType, isFromMypage: Bool = false @@ -53,20 +53,20 @@ final class OnboardingView: BaseViewController { fatalError("init(coder:) has not been implemented") } - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillAppear(_ animated: Bool) { + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) let stepCount = OnboardingType.allCases.count + 1 - configureNavigationBar(navigationStyle: .withPrograssBar(step: onboarding.step, stepCount: stepCount)) + if !isFromMypage && onboarding == .time { + configureNavigationBar(navigationStyle: .withPrograssBarWithoutBackButton(step: onboarding.step, stepCount: stepCount)) + } else { + configureNavigationBar(navigationStyle: .withPrograssBar(step: onboarding.step, stepCount: stepCount)) + } self.viewModel.action(input: .fetchOnboardingChoice(onboarding: onboarding)) } - override func viewDidLayoutSubviews() { + public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if !isLayoutConfigured { diff --git a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift index e14028c9..f958b2a2 100644 --- a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift +++ b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift @@ -9,15 +9,15 @@ import Combine import Domain import Shared -final class OnboardingViewModel: ViewModel { - enum Input { +public final class OnboardingViewModel: ViewModel { + public enum Input { case selectOnboardingChoice(selectedChoice: OnboardingChoiceType) case fetchOnboardingChoice(onboarding: OnboardingType) case fetchOnboardingChoices case makeOnboardingResult } - struct Output { + public struct Output { let timeOnboardingChoicePublisher: AnyPublisher let frequencyOnboardingChoicePublisher: AnyPublisher let feelingOnboardingChoicePublisher: AnyPublisher, Never> @@ -48,7 +48,7 @@ final class OnboardingViewModel: ViewModel { ) } - func action(input: Input) { + public func action(input: Input) { switch input { case .selectOnboardingChoice(let selectedChoice): selectChoice(choice: selectedChoice)