diff --git a/Projects/Core/Services/Sources/SocialLogin/AppleLogin.swift b/Projects/Core/Services/Sources/SocialLogin/AppleLogin.swift index 70be034a..368bf945 100644 --- a/Projects/Core/Services/Sources/SocialLogin/AppleLogin.swift +++ b/Projects/Core/Services/Sources/SocialLogin/AppleLogin.swift @@ -11,9 +11,10 @@ import AuthenticationServices import Models -enum AppleErrorType: Error { +public enum AppleErrorType: Error { case invalidToken case invalidAuthorizationCode + case dismissASAuthorizationController } final class AppleLogin: NSObject, ASAuthorizationControllerDelegate { @@ -76,8 +77,18 @@ final class AppleLogin: NSObject, ASAuthorizationControllerDelegate { } } + @MainActor func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - continuation?.resume(throwing: error) - continuation = nil + if let authError = error as? ASAuthorizationError { + switch authError.code { + case .canceled: + continuation?.resume(throwing: AppleErrorType.dismissASAuthorizationController) + continuation = nil + + default: + continuation?.resume(throwing: authError) + continuation = nil + } + } } } diff --git a/Projects/Core/Services/Sources/UserDefaults/UserDefaultsClient.swift b/Projects/Core/Services/Sources/UserDefaults/UserDefaultsClient.swift index 2e1c9d7d..18a25b1f 100644 --- a/Projects/Core/Services/Sources/UserDefaults/UserDefaultsClient.swift +++ b/Projects/Core/Services/Sources/UserDefaults/UserDefaultsClient.swift @@ -11,112 +11,115 @@ import Foundation import ComposableArchitecture public struct UserDefaultsClient { - public enum UserDefaultsKey: String { - case fcmToken - case isPopGestureEnabled - case recentSearches - case latestUnsavedSummaryFeedId + public enum UserDefaultsKey: String { + case isFirstLaunch + case fcmToken + case isPopGestureEnabled + case recentSearches + case latestUnsavedSummaryFeedId + } + + public var string: @Sendable (_ forKey: UserDefaultsKey, _ default: String) -> String + public var integer: @Sendable (_ forKey: UserDefaultsKey, _ default: Int) -> Int + public var bool: @Sendable (_ forKey: UserDefaultsKey, _ default: Bool) -> Bool + public var float: @Sendable (_ forKey: UserDefaultsKey, _ default: Float) -> Float + public var double: @Sendable (_ forKey: UserDefaultsKey, _ default: Double) -> Double + public var data: @Sendable (_ forKey: UserDefaultsKey, _ default: Data) -> Data + public var stringArray: @Sendable (_ forKey: UserDefaultsKey, _ default: [String]) -> [String] + public var object: @Sendable (_ forKey: UserDefaultsKey, _ default: Any) -> Any + public var set: @Sendable (_ value: Any, _ forKey: UserDefaultsKey) -> Void + public var removeObject: @Sendable (_ forKey: UserDefaultsKey) -> Void + public var reset: @Sendable () -> Void + + public func codableObject(_ type: T.Type, forKey key: String, defaultValue: T) -> T { + guard let data = UserDefaults.standard.data(forKey: key) else { + return defaultValue } - public var string: @Sendable (_ forKey: UserDefaultsKey, _ default: String) -> String - public var integer: @Sendable (_ forKey: UserDefaultsKey, _ default: Int) -> Int - public var bool: @Sendable (_ forKey: UserDefaultsKey, _ default: Bool) -> Bool - public var float: @Sendable (_ forKey: UserDefaultsKey, _ default: Float) -> Float - public var double: @Sendable (_ forKey: UserDefaultsKey, _ default: Double) -> Double - public var data: @Sendable (_ forKey: UserDefaultsKey, _ default: Data) -> Data - public var stringArray: @Sendable (_ forKey: UserDefaultsKey, _ default: [String]) -> [String] - public var object: @Sendable (_ forKey: UserDefaultsKey, _ default: Any) -> Any - public var set: @Sendable (_ value: Any, _ forKey: UserDefaultsKey) -> Void - public var removeObject: @Sendable (_ forKey: UserDefaultsKey) -> Void - public var reset: @Sendable () -> Void - - public func codableObject(_ type: T.Type, forKey key: String, defaultValue: T) -> T { - guard let data = UserDefaults.standard.data(forKey: key) else { - return defaultValue - } - - let decoder = JSONDecoder() - do { - let object = try decoder.decode(type, from: data) - return object - } catch { - print("Failed to decode \(type) from UserDefaults with key \(key): \(error)") - return defaultValue - } + let decoder = JSONDecoder() + do { + let object = try decoder.decode(type, from: data) + return object + } catch { + print("Failed to decode \(type) from UserDefaults with key \(key): \(error)") + return defaultValue } - - public func setCodable(_ value: T, forKey key: String) { - let encoder = JSONEncoder() - do { - let data = try encoder.encode(value) - UserDefaults.standard.set(data, forKey: key) - } catch { - print("Failed to encode \(value) for key \(key): \(error)") - } + } + + public func setCodable(_ value: T, forKey key: String) { + let encoder = JSONEncoder() + do { + let data = try encoder.encode(value) + UserDefaults.standard.set(data, forKey: key) + } catch { + print("Failed to encode \(value) for key \(key): \(error)") } + } } extension UserDefaultsClient: DependencyKey { - static func userDefaultsObject(_ type: T.Type, forKey key: String, defaultValue: T) -> T { - guard let value = UserDefaults.standard.object(forKey: key) as? T else { - return defaultValue - } - - return value + static func userDefaultsObject(_ type: T.Type, forKey key: String, defaultValue: T) -> T { + guard let value = UserDefaults.standard.object(forKey: key) as? T else { + return defaultValue } + + return value + } static func userDefaultsArray(_ type: T.Type, forKey key: String, defaultValue: T) -> T { - guard let value = UserDefaults.standard.array(forKey: key) as? T else { - return defaultValue + guard let value = UserDefaults.standard.array(forKey: key) as? T else { + return defaultValue + } + + return value + } + + public static var liveValue: UserDefaultsClient { + return Self( + string: { key, defaultValue in + return userDefaultsObject(String.self, forKey: key.rawValue, defaultValue: defaultValue) + }, + integer: { key, defaultValue in + return userDefaultsObject(Int.self, forKey: key.rawValue, defaultValue: defaultValue) + }, + bool: { key, defaultValue in + return userDefaultsObject(Bool.self, forKey: key.rawValue, defaultValue: defaultValue) + }, + float: { key, defaultValue in + return userDefaultsObject(Float.self, forKey: key.rawValue, defaultValue: defaultValue) + }, + double: { key, defaultValue in + return userDefaultsObject(Double.self, forKey: key.rawValue, defaultValue: defaultValue) + }, + data: { key, defaultValue in + return userDefaultsObject(Data.self, forKey: key.rawValue, defaultValue: defaultValue) + }, + stringArray: { key, defaultValue in + return userDefaultsArray([String].self, forKey: key.rawValue, defaultValue: defaultValue) + }, + object: { key, defaultValue in + return UserDefaults.standard.object(forKey: key.rawValue) ?? defaultValue + }, + set: { value, key in + UserDefaults.standard.set(value, forKey: key.rawValue) + }, + removeObject: { key in + UserDefaults.standard.removeObject(forKey: key.rawValue) + }, + reset: { + for key in UserDefaults.standard.dictionaryRepresentation().keys { + if key != UserDefaultsKey.isFirstLaunch.rawValue { + UserDefaults.standard.removeObject(forKey: key.description) + } + } } - - return value + ) } - - public static var liveValue: UserDefaultsClient { - return Self( - string: { key, defaultValue in - return userDefaultsObject(String.self, forKey: key.rawValue, defaultValue: defaultValue) - }, - integer: { key, defaultValue in - return userDefaultsObject(Int.self, forKey: key.rawValue, defaultValue: defaultValue) - }, - bool: { key, defaultValue in - return userDefaultsObject(Bool.self, forKey: key.rawValue, defaultValue: defaultValue) - }, - float: { key, defaultValue in - return userDefaultsObject(Float.self, forKey: key.rawValue, defaultValue: defaultValue) - }, - double: { key, defaultValue in - return userDefaultsObject(Double.self, forKey: key.rawValue, defaultValue: defaultValue) - }, - data: { key, defaultValue in - return userDefaultsObject(Data.self, forKey: key.rawValue, defaultValue: defaultValue) - }, - stringArray: { key, defaultValue in - return userDefaultsArray([String].self, forKey: key.rawValue, defaultValue: defaultValue) - }, - object: { key, defaultValue in - return UserDefaults.standard.object(forKey: key.rawValue) ?? defaultValue - }, - set: { value, key in - UserDefaults.standard.set(value, forKey: key.rawValue) - }, - removeObject: { key in - UserDefaults.standard.removeObject(forKey: key.rawValue) - }, - reset: { - for key in UserDefaults.standard.dictionaryRepresentation().keys { - UserDefaults.standard.removeObject(forKey: key.description) - } - } - ) - } } public extension DependencyValues { - var userDefaultsClient: UserDefaultsClient { - get { self[UserDefaultsClient.self] } - set { self[UserDefaultsClient.self] = newValue } - } + var userDefaultsClient: UserDefaultsClient { + get { self[UserDefaultsClient.self] } + set { self[UserDefaultsClient.self] = newValue } + } } diff --git a/Projects/Feature/Scene/Auth/Login/LoginFeature.swift b/Projects/Feature/Scene/Auth/Login/LoginFeature.swift index 5db7180c..a7b560dc 100644 --- a/Projects/Feature/Scene/Auth/Login/LoginFeature.swift +++ b/Projects/Feature/Scene/Auth/Login/LoginFeature.swift @@ -21,6 +21,7 @@ public struct LoginFeature { @ObservableState public struct State: Equatable { var loginInfo: SocialLoginInfo? + var isLoading: Bool = false public init() {} } @@ -41,6 +42,7 @@ public struct LoginFeature { case setSocialLoginInfo(SocialLoginInfo) case setSaveKeychain(TokenInfo) case setSaveAnalyticsUserId(String) + case setLoading(Bool) // MARK: Delegate Action public enum Delegate { @@ -49,9 +51,13 @@ public struct LoginFeature { } case delegate(Delegate) + + // MARK: Present Action + case loginFailAlertPresented } @Dependency(AnalyticsClient.self) private var analyticsClient + @Dependency(\.alertClient) private var alertClient @Dependency(\.userDefaultsClient) private var userDefaultsClient @Dependency(\.keychainClient) private var keychainClient @Dependency(\.socialLogin) private var socialLogin @@ -76,11 +82,15 @@ public struct LoginFeature { return .run( operation: { send in + await send(.setLoading(true)) + let info = try await socialLogin.kakaoLogin() await send(.login(info)) }, catch: { error, send in debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailAlertPresented) } ) .throttle(id: ThrottleId.kakaoLoginButton, for: .seconds(1), scheduler: DispatchQueue.main, latest: false) @@ -90,11 +100,24 @@ public struct LoginFeature { return .run( operation: { send in + await send(.setLoading(true)) + let info = try await socialLogin.appleLogin() await send(.login(info)) }, catch: { error, send in - debugPrint(error) + await send(.setLoading(false)) + + guard let appleAuthError = error as? AppleErrorType else { + await send(.loginFailAlertPresented) + return + } + + if case .dismissASAuthorizationController = appleAuthError { + return + } + + await send(.loginFailAlertPresented) } ) .throttle(id: ThrottleId.appleLoginButton, for: .seconds(1), scheduler: DispatchQueue.main, latest: false) @@ -120,6 +143,8 @@ public struct LoginFeature { }, catch: { error, send in debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailAlertPresented) } ) @@ -130,6 +155,9 @@ public struct LoginFeature { let folderList = try await folderListResponse + await send(.setLoading(false)) + try? await Task.sleep(for: .seconds(0.2)) + if folderList.isEmpty { await send(.delegate(.moveToOnboarding)) } else { @@ -138,20 +166,21 @@ public struct LoginFeature { }, catch: { error, send in debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailAlertPresented) } ) case .putFcmPushToken: return .run( operation: { send in - guard !userDefaultsClient.string(.fcmToken, "").isEmpty else { - return - } - + guard !userDefaultsClient.string(.fcmToken, "").isEmpty else { return } try await userClient.putFcmPushToken(userDefaultsClient.string(.fcmToken, "")) }, catch: { error, send in debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailAlertPresented) } ) @@ -160,21 +189,50 @@ public struct LoginFeature { return .none case let .setSaveKeychain(token): - return .run { send in - try await keychainClient.save(.accessToken, token.accessToken) - try await keychainClient.save(.refreshToken, token.refreshToken) - - await send(.putFcmPushToken) - await send(.setSaveAnalyticsUserId(token.accessToken)) - /// 폴더 유무로 첫 가입 유저 확인 - await send(.fetchFolderList) - - } + return .run( + operation: { send in + try await keychainClient.save(.accessToken, token.accessToken) + try await keychainClient.save(.refreshToken, token.refreshToken) + + await send(.putFcmPushToken) + await send(.setSaveAnalyticsUserId(token.accessToken)) + await send(.fetchFolderList) + }, + catch: { error, send in + debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailAlertPresented) + } + ) case let .setSaveAnalyticsUserId(accessToken): - return .run { _ in - let userId = try await authClient.decodeUserId(accessToken) - analyticsClient.setUserId(userId) + return .run( + operation: { send in + let userId = try await authClient.decodeUserId(accessToken) + analyticsClient.setUserId(userId) + }, + catch: { error, send in + debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailAlertPresented) + } + ) + + case let .setLoading(isLoading): + state.isLoading = isLoading + return .none + + case .loginFailAlertPresented: + return .run { send in + await alertClient.present(.init( + title: "로그인 실패", + description: """ + 로그인에 실패하였습니다. + 잠시 후 다시 시도해주세요. + """, + buttonType: .singleButton(), + rightButtonAction: {} + )) } default: diff --git a/Projects/Feature/Scene/Auth/Login/LoginView.swift b/Projects/Feature/Scene/Auth/Login/LoginView.swift index 8efc3324..5bbed580 100644 --- a/Projects/Feature/Scene/Auth/Login/LoginView.swift +++ b/Projects/Feature/Scene/Auth/Login/LoginView.swift @@ -24,36 +24,38 @@ public struct LoginView: View { public var body: some View { NavigationStack { WithPerceptionTracking { - ZStack { - Color.white + VStack(alignment: .center, spacing: 0) { + Spacer() + Spacer() - VStack(alignment: .center, spacing: 0) { - Spacer() - Spacer() - - logo - title + logo + title + + Spacer() + Spacer() + Spacer() + + VStack(spacing: 12) { + makeLoginButton(action: { + HapticFeedbackManager.shared.impact(style: .light) + store.send(.kakaoLoginButtonTapped) + }, backgroundColor: .bkColor(.kakaoYellow), title: "카카오톡으로 시작하기", titleColor: .bkColor(.gray900), buttonImage: CommonFeature.Images.icokakao, buttonImageColor: .bkColor(.gray900)) - Spacer() - Spacer() - Spacer() + makeLoginButton(action: { + HapticFeedbackManager.shared.impact(style: .light) + store.send(.appleLoginButtonTapped) + }, backgroundColor: .bkColor(.black), title: "Apple로 시작하기", titleColor: .bkColor(.white), buttonImage: CommonFeature.Images.icoapple, buttonImageColor: .bkColor(.white)) - VStack(spacing: 12) { - makeLoginButton(action: { - HapticFeedbackManager.shared.impact(style: .light) - store.send(.kakaoLoginButtonTapped) - }, backgroundColor: .bkColor(.kakaoYellow), title: "카카오톡으로 시작하기", titleColor: .bkColor(.gray900), buttonImage: CommonFeature.Images.icokakao, buttonImageColor: .bkColor(.gray900)) - - makeLoginButton(action: { - HapticFeedbackManager.shared.impact(style: .light) - store.send(.appleLoginButtonTapped) - }, backgroundColor: .bkColor(.black), title: "Apple로 시작하기", titleColor: .bkColor(.white), buttonImage: CommonFeature.Images.icoapple, buttonImageColor: .bkColor(.white)) - - makeTerms( - serviceTerms:makeTermsText("서비스 약관", url: BKExternalURL.termOfUse.urlString), - privacyPolicy: makeTermsText("개인정보 처리방침", url: BKExternalURL.privacy.urlString) - ) - } + makeTerms( + serviceTerms:makeTermsText("서비스 약관", url: BKExternalURL.termOfUse.urlString), + privacyPolicy: makeTermsText("개인정보 처리방침", url: BKExternalURL.privacy.urlString) + ) + } + } + .background(.white) + .if(store.isLoading) { view in + view.overlay { + LoginIndicator() } } } @@ -138,3 +140,15 @@ public struct LoginView: View { return attributedString } } + +private struct LoginIndicator: View { + var body: some View { + VStack { + Spacer() + BKLoadingIndicator() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .ignoresSafeArea() + } +} diff --git a/Projects/Feature/Scene/Common/BKCard/BKCardView.swift b/Projects/Feature/Scene/Common/BKCard/BKCardView.swift index 365bf0d0..4308b423 100644 --- a/Projects/Feature/Scene/Common/BKCard/BKCardView.swift +++ b/Projects/Feature/Scene/Common/BKCard/BKCardView.swift @@ -46,9 +46,14 @@ struct BKCardView: View { @ViewBuilder private func loadingView() -> some View { - BKLoadingIndicator() - .frame(maxWidth: .infinity, minHeight: emptyHeight) - .background(Color.bkColor(.gray300)) + VStack { + Spacer() + BKLoadingIndicator() + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, minHeight: emptyHeight) + .background(Color.bkColor(.gray300)) } @ViewBuilder diff --git a/Projects/Feature/Scene/Link/Link/Component/LinkHeaderView.swift b/Projects/Feature/Scene/Link/Link/Component/LinkHeaderView.swift index 87459d6e..4d47df51 100644 --- a/Projects/Feature/Scene/Link/Link/Component/LinkHeaderView.swift +++ b/Projects/Feature/Scene/Link/Link/Component/LinkHeaderView.swift @@ -48,16 +48,15 @@ struct LinkHeaderView: View { .scaledToFill() } } + .dimmedBackground() .frame(width: size.width, height: size.height + (isScrolling ? minY : 0)) .clipped() .offset(y: isScrolling ? -minY : 0) - .opacity(0.56) .overlay(alignment: .bottom) { VStack(spacing: 0) { titleView() buttonView } - .opacity(1.0) .padding(EdgeInsets(top: Size.topSafeAreaInset + Size.navigationBarHeight, leading: 16, bottom: 24, trailing: 16)) .offset(y: isScrolling ? -minY : 0) } diff --git a/Projects/Feature/Scene/Root/RootFeature.swift b/Projects/Feature/Scene/Root/RootFeature.swift index e7d57cc8..040f9526 100644 --- a/Projects/Feature/Scene/Root/RootFeature.swift +++ b/Projects/Feature/Scene/Root/RootFeature.swift @@ -8,15 +8,12 @@ import Foundation -import Analytics -import Common -import Models import Services import ComposableArchitecture @Reducer -public struct RootFeature: Reducer { +public struct RootFeature { public init() {} @ObservableState @@ -36,14 +33,9 @@ public struct RootFeature: Reducer { case onOpenURL(URL) // MARK: Inner Business Action - case refreshToken(Result) - case putFcmPushToken(Result) // MARK: Inner SetState Action case changeScreen(State) - case setUpdateToken(TokenInfo) - case setSaveAnalyticsUserId(String) - case setPopGestureEnabled(Bool) // MARK: Child Action case splash(SplashFeature.Action) @@ -53,75 +45,28 @@ public struct RootFeature: Reducer { case mainTab(BKTabFeature.Action) } - @Dependency(AnalyticsClient.self) private var analyticsClient - @Dependency(\.userDefaultsClient) private var userDefaultsClient - @Dependency(\.keychainClient) private var keychainClient @Dependency(\.socialLogin) private var socialLogin - @Dependency(\.authClient) private var authClient - @Dependency(\.userClient) private var userClient public var body: some ReducerOf { Reduce { state, action in switch action { case .onAppear: - return .run { send in - try await Task.sleep(for: .seconds(2)) - - await send(.setPopGestureEnabled(true)) - - if keychainClient.checkToTokenIsExist() { - await send(.changeScreen(.login())) - } else { - await send(.refreshToken(Result { try await authClient.requestRegenerateToken(keychainClient.read(.refreshToken)) })) - } - } + return .none case let .onOpenURL(url): socialLogin.handleKakaoUrl(url) return .none - case let .refreshToken(.success(token)): - return .run { send in - await send(.setUpdateToken(token)) - await send(.setSaveAnalyticsUserId(token.accessToken)) - - guard !userDefaultsClient.string(.fcmToken, "").isEmpty else { - await send(.changeScreen(.mainTab())) - return - } - - await send(.putFcmPushToken(Result { try await userClient.putFcmPushToken(userDefaultsClient.string(.fcmToken, "")) })) - await send(.changeScreen(.mainTab())) - } - - case .refreshToken(.failure): - return .send(.changeScreen(.login())) - - case .putFcmPushToken(.success): - return .none - - case .putFcmPushToken(.failure): - return .send(.changeScreen(.mainTab())) - case let .changeScreen(newState): state = newState return .none - case let .setUpdateToken(token): - return .run { _ in - try await keychainClient.update(.accessToken, token.accessToken) - try await keychainClient.update(.refreshToken, token.refreshToken) - } - - case let .setSaveAnalyticsUserId(accessToken): - return .run { _ in - let userId = try await authClient.decodeUserId(accessToken) - analyticsClient.setUserId(userId) - } + /// - Splash Delegate + case .splash(.delegate(.login)): + return .send(.changeScreen(.login())) - case let .setPopGestureEnabled(isEnabled): - userDefaultsClient.set(isEnabled, .isPopGestureEnabled) - return .none + case .splash(.delegate(.main)): + return .send(.changeScreen(.mainTab())) /// - MainTab Delegate case .mainTab(.delegate(.logout)), .mainTab(.delegate(.signout)): diff --git a/Projects/Feature/Scene/Setting/View/SettingFeature.swift b/Projects/Feature/Scene/Setting/View/SettingFeature.swift index 632ac29d..c2f14547 100644 --- a/Projects/Feature/Scene/Setting/View/SettingFeature.swift +++ b/Projects/Feature/Scene/Setting/View/SettingFeature.swift @@ -17,7 +17,7 @@ import ComposableArchitecture public struct SettingFeature { @ObservableState public struct State: Equatable { - var nickname: String = "블링크" + var nickname: String = "" var validationNoticeMessage: String = "" var currentAppVersion: String = Bundle.currentAppVersion var latestAppVersion: String = "Unknown" @@ -46,8 +46,9 @@ public struct SettingFeature { case versionInfo case tappedServiceInfo case tappedLogOut - case tappedWithdrawCell + case logoutAlertRightButtonTapped case signoutButtonTapped + case signoutAlertRightButtonTapped case changeConfirmWithdrawModal case confirmedWithdrawWarning case tappedCompletedEditingNickname @@ -70,7 +71,6 @@ public struct SettingFeature { case delegate(Delegate) } - private enum NicknameValidationNotice { case notAllowOthreLanguage case notAllowTextSymbol @@ -102,6 +102,7 @@ public struct SettingFeature { private enum ThrottleId { case logoutButton + case signoutButton } public var body: some ReducerOf { @@ -142,18 +143,36 @@ public struct SettingFeature { title: "로그아웃", description: "정말 로그아웃 하시겠어요?", buttonType: .doubleButton(left: "아니오", right: "로그아웃"), - rightButtonAction: { await send(.postLogout) }) + rightButtonAction: { await send(.logoutAlertRightButtonTapped) }) ) } .throttle(id: ThrottleId.logoutButton, for: .seconds(1), scheduler: DispatchQueue.main, latest: false) - case .tappedWithdrawCell: - state.showWithdrawModal = true - return .none + case .logoutAlertRightButtonTapped: + return .concatenate( + .send(.postLogout), + .merge( + .send(.setDeleteKeychain), + .send(.setDeleteUserDefaults) + ), + .send(.delegate(.logout)) + ) case .signoutButtonTapped: - return .send(.postSignout) + state.showWithdrawModal = true + return .none + .throttle(id: ThrottleId.signoutButton, for: .seconds(1), scheduler: DispatchQueue.main, latest: false) + case .signoutAlertRightButtonTapped: + return .concatenate( + .send(.postSignout), + .merge( + .send(.setDeleteKeychain), + .send(.setDeleteUserDefaults) + ), + .send(.delegate(.signout)) + ) + case .tappedNotice: state.noticeContent = .init() return .none @@ -177,16 +196,12 @@ public struct SettingFeature { let response = try await userClient.requestUserProfile(targetNickName) await send(.changeNickName(targetNickname: response.nickname)) } - + case .postLogout: return .run( operation: { send in let refreshToken = keychainClient.read(.refreshToken) - try await authClient.logout(refreshToken) - - await send(.setDeleteKeychain) - await send(.delegate(.logout)) }, catch : { error, send in print(error) @@ -197,12 +212,7 @@ public struct SettingFeature { return .run( operation: { send in let refreshToken = keychainClient.read(.refreshToken) - try await authClient.signout(refreshToken) - - await send(.setDeleteKeychain) - await send(.setDeleteUserDefaults) - await send(.delegate(.signout)) }, catch : { error, send in print(error) diff --git a/Projects/Feature/Scene/Setting/View/SettingView.swift b/Projects/Feature/Scene/Setting/View/SettingView.swift index 5a445c8d..ec2ecad6 100644 --- a/Projects/Feature/Scene/Setting/View/SettingView.swift +++ b/Projects/Feature/Scene/Setting/View/SettingView.swift @@ -44,7 +44,7 @@ public struct SettingView: View { ), destination: { store in NoticeView(store: store) }) - .signoutAlert(isPresented: $store.showWithdrawModal, buttonAction: { store.send(.signoutButtonTapped) }) + .signoutAlert(isPresented: $store.showWithdrawModal, buttonAction: { store.send(.signoutAlertRightButtonTapped) }) .onAppear(perform: { store.send(.requestSettingInfo) }) @@ -161,7 +161,7 @@ extension SettingView { Button(action: { HapticFeedbackManager.shared.notification(type: .error) - store.send(.tappedWithdrawCell) + store.send(.signoutButtonTapped) }, label: { Text("회원탈퇴") .font(.regular(size: ._12)) diff --git a/Projects/Feature/Scene/Splash/SplashFeature.swift b/Projects/Feature/Scene/Splash/SplashFeature.swift index a859825c..7e964ec0 100644 --- a/Projects/Feature/Scene/Splash/SplashFeature.swift +++ b/Projects/Feature/Scene/Splash/SplashFeature.swift @@ -8,24 +8,152 @@ import Foundation +import Analytics +import Common +import Models +import Services + import ComposableArchitecture @Reducer -public struct SplashFeature: Reducer { +public struct SplashFeature { public init() {} public struct State: Equatable { public init() {} } - public enum Action: Equatable { + public enum Action { + // MARK: User Action case onAppear + + // MARK: Inner Business Action + case migrateDeviceInfo + case refreshToken(Result) + case putFcmPushToken(Result) + + // MARK: Inner SetState Action + case setUpdateToken(TokenInfo) + case setSaveAnalyticsUserId(String) + case setPopGestureEnabled(Bool) + case setFirstLaunch(Bool) + case setDeleteKeychain + case setDeleteUserDefaults + + // MARK: Delegate Action + public enum Delegate { + case login + case main + } + case delegate(Delegate) } + @Dependency(AnalyticsClient.self) private var analyticsClient + @Dependency(\.userDefaultsClient) private var userDefaultsClient + @Dependency(\.keychainClient) private var keychainClient + @Dependency(\.socialLogin) private var socialLogin + @Dependency(\.authClient) private var authClient + @Dependency(\.userClient) private var userClient + public var body: some ReducerOf { Reduce { state, action in switch action { case .onAppear: + return .run { send in + if userDefaultsClient.bool(.isFirstLaunch, true) { + await send(.migrateDeviceInfo) + } + + try await Task.sleep(for: .seconds(2)) + + if keychainClient.checkToTokenIsExist() { + return await send(.delegate(.login)) + } else { + return await send(.refreshToken(Result { try await authClient.requestRegenerateToken(keychainClient.read(.refreshToken)) })) + } + } + + case .migrateDeviceInfo: + return .concatenate( + .merge( + .send(.setDeleteKeychain), + .send(.setDeleteUserDefaults) + ), + .merge( + .send(.setPopGestureEnabled(true)), + .send(.setFirstLaunch(false)) + ) + ) + + case let .refreshToken(.success(token)): + return .run { send in + await send(.setUpdateToken(token)) + await send(.setSaveAnalyticsUserId(token.accessToken)) + + guard !userDefaultsClient.string(.fcmToken, "").isEmpty else { + await send(.delegate(.main)) + return + } + + await send(.putFcmPushToken(Result { try await userClient.putFcmPushToken(userDefaultsClient.string(.fcmToken, "")) })) + await send(.delegate(.main)) + } + + case .refreshToken(.failure): + return .send(.delegate(.login)) + + case .putFcmPushToken(.success): + return .none + + case .putFcmPushToken(.failure): + return .send(.delegate(.main)) + + case let .setUpdateToken(token): + return .run( + operation: { send in + try await keychainClient.update(.accessToken, token.accessToken) + try await keychainClient.update(.refreshToken, token.refreshToken) + } + ,catch: { error, send in + debugPrint(error) + } + ) + + case let .setSaveAnalyticsUserId(accessToken): + return .run( + operation: { send in + let userId = try await authClient.decodeUserId(accessToken) + analyticsClient.setUserId(userId) + } + ,catch: { error, send in + debugPrint(error) + } + ) + + case let .setPopGestureEnabled(isEnabled): + userDefaultsClient.set(isEnabled, .isPopGestureEnabled) + return .none + + case let .setFirstLaunch(isFirstLaunch): + userDefaultsClient.set(isFirstLaunch, .isFirstLaunch) + return .none + + case .setDeleteKeychain: + return .run( + operation: { send in + try await keychainClient.delete(.accessToken) + try await keychainClient.delete(.refreshToken) + } + ,catch: { error, send in + debugPrint(error) + } + ) + + case .setDeleteUserDefaults: + userDefaultsClient.reset() + return .none + + default: return .none } } diff --git a/Projects/Feature/Scene/Splash/SplashView.swift b/Projects/Feature/Scene/Splash/SplashView.swift index a0d6cb9b..0b2cc9d0 100644 --- a/Projects/Feature/Scene/Splash/SplashView.swift +++ b/Projects/Feature/Scene/Splash/SplashView.swift @@ -37,6 +37,7 @@ public struct SplashView: View { .scaledToFill() .frame(width: proxy.size.width, height: proxy.size.height) ) + .onAppear { store.send(.onAppear) } } } } diff --git a/Projects/Shared/CommonFeature/Sources/DesignSystem/Component/LoadingIndicator/BKLoadingIndicator.swift b/Projects/Shared/CommonFeature/Sources/DesignSystem/Component/LoadingIndicator/BKLoadingIndicator.swift index 1c19199c..a1ac9726 100644 --- a/Projects/Shared/CommonFeature/Sources/DesignSystem/Component/LoadingIndicator/BKLoadingIndicator.swift +++ b/Projects/Shared/CommonFeature/Sources/DesignSystem/Component/LoadingIndicator/BKLoadingIndicator.swift @@ -14,20 +14,13 @@ public struct BKLoadingIndicator: View { public init() {} public var body: some View { - VStack { - Spacer() - - LottieView( - animation: .named( - "loadingIndicator", - bundle: CommonFeatureResources.bundle - ) + LottieView( + animation: .named( + "loadingIndicator", + bundle: CommonFeatureResources.bundle ) - .playing(loopMode: .loop) - .backgroundBehavior(.pauseAndRestore) - - Spacer() - Spacer() - } + ) + .playing(loopMode: .loop) + .backgroundBehavior(.pauseAndRestore) } } diff --git a/Projects/Shared/CommonFeature/Sources/Enum/BKExternalURL.swift b/Projects/Shared/CommonFeature/Sources/Enum/BKExternalURL.swift index 5b5bd777..8b77cb18 100644 --- a/Projects/Shared/CommonFeature/Sources/Enum/BKExternalURL.swift +++ b/Projects/Shared/CommonFeature/Sources/Enum/BKExternalURL.swift @@ -22,7 +22,7 @@ extension BKExternalURL { case .termOfUse: return "https://daffy-sandal-6ef.notion.site/c784f55ca8164c669845d3569cd6683a" case .introduceService: - return "https://daffy-sandal-6ef.notion.site/100-5d76361912514364864547cbc1600531?pvs=4" + return "https://daffy-sandal-6ef.notion.site/6addddc3f4164264b4fc58d01cbfd706?pvs=4" } } public var url: URL { diff --git a/Projects/Shared/CommonFeature/Sources/ViewExtensions/View+.swift b/Projects/Shared/CommonFeature/Sources/ViewExtensions/View+.swift index d82af46b..abb58008 100644 --- a/Projects/Shared/CommonFeature/Sources/ViewExtensions/View+.swift +++ b/Projects/Shared/CommonFeature/Sources/ViewExtensions/View+.swift @@ -44,3 +44,9 @@ public extension View { } } } + +public extension View { + func dimmedBackground(_ opacity: Double = 0.56) -> some View { + self.overlay { Color.black.opacity(opacity) } + } +}