diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift index 254f6512..218eada7 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift @@ -48,6 +48,7 @@ public extension ModulePath { public extension ModulePath { enum Domain: String, CaseIterable { + case Error case User case Report case WebView diff --git a/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift b/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift index a52ef8e2..135450c4 100644 --- a/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift +++ b/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift @@ -18,6 +18,7 @@ public enum AuthAPI { case logout(_ logOutRequestDTO: LogOutRequestDTO) case revoke case profile(_ requestDTO: ProfileRequestDTO) + case updateVersion } extension AuthAPI: BaseTargetType { @@ -35,6 +36,8 @@ extension AuthAPI: BaseTargetType { return "api/v1/auth/apple/revoke" case .profile: return "api/v2/auth/profile" + case .updateVersion: + return "api/v1/auth/app-version" } } @@ -52,6 +55,8 @@ extension AuthAPI: BaseTargetType { return .get case .profile: return .post + case .updateVersion: + return .get } } @@ -69,6 +74,8 @@ extension AuthAPI: BaseTargetType { return .requestPlain case .profile(let requestDTO): return .requestJSONEncodable(requestDTO) + case .updateVersion: + return .requestPlain } } } diff --git a/Projects/Domain/Auth/Interface/Sources/AuthClient.swift b/Projects/Domain/Auth/Interface/Sources/AuthClient.swift index 98831064..81d65931 100644 --- a/Projects/Domain/Auth/Interface/Sources/AuthClient.swift +++ b/Projects/Domain/Auth/Interface/Sources/AuthClient.swift @@ -19,6 +19,7 @@ public struct AuthClient { private let fetchAppleClientSecret: () async throws -> String private let registerUserProfile: (String) async throws -> Void private let _removeAllToken: () -> Void + private let checkUpdateVersion: () async throws -> Void public init( signInWithKakao: @escaping () async throws -> UserInfo, @@ -31,7 +32,8 @@ public struct AuthClient { revokeAppleLogin: @escaping () async throws -> Void, fetchAppleClientSecret: @escaping () async throws -> String, registerUserProfile: @escaping (String) async throws -> Void, - removeAllToken: @escaping () -> Void + removeAllToken: @escaping () -> Void, + checkUpdateVersion: @escaping () async throws -> Void ) { self.signInWithKakao = signInWithKakao self.signInWithApple = signInWithApple @@ -44,6 +46,7 @@ public struct AuthClient { self.fetchAppleClientSecret = fetchAppleClientSecret self.registerUserProfile = registerUserProfile self._removeAllToken = removeAllToken + self.checkUpdateVersion = checkUpdateVersion } public func signInWithKakao() async throws -> UserInfo { @@ -88,5 +91,9 @@ public struct AuthClient { public func removeAllToken() { _removeAllToken() } + + public func checkUpdateVersion() async throws { + return try await checkUpdateVersion() + } } diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Response/UpdateVersionResponseDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Response/UpdateVersionResponseDTO.swift new file mode 100644 index 00000000..7445d20b --- /dev/null +++ b/Projects/Domain/Auth/Interface/Sources/DTO/Response/UpdateVersionResponseDTO.swift @@ -0,0 +1,12 @@ +// +// UpdateVersionResponseDTO.swift +// DomainAuthInterface +// +// Created by JongHoon on 9/10/24. +// + +import Foundation + +public struct UpdateVersionResponseDTO: Decodable { + public let minimumIosVersion: Int? +} diff --git a/Projects/Domain/Auth/Project.swift b/Projects/Domain/Auth/Project.swift index d92862ca..ff0338f1 100644 --- a/Projects/Domain/Auth/Project.swift +++ b/Projects/Domain/Auth/Project.swift @@ -17,7 +17,8 @@ let project = Project.makeModule( implements: .Auth, factory: .init( dependencies: [ - .domain(interface: .Auth) + .domain(interface: .Auth), + .domain(interface: .Error) ] ) ), diff --git a/Projects/Domain/Auth/Sources/AuthClient.swift b/Projects/Domain/Auth/Sources/AuthClient.swift index d0c0b081..86c0ebf3 100644 --- a/Projects/Domain/Auth/Sources/AuthClient.swift +++ b/Projects/Domain/Auth/Sources/AuthClient.swift @@ -8,6 +8,7 @@ import Foundation import DomainAuthInterface +import DomainErrorInterface import DomainUser import CoreNetwork @@ -91,6 +92,22 @@ extension AuthClient: DependencyKey { }, removeAllToken: { LocalAuthDataSourceImpl.removeAllToken() + }, + checkUpdateVersion: { + let minimumIosBuildNumber = try await networkManager.reqeust(api: .apiType(AuthAPI.updateVersion), dto: UpdateVersionResponseDTO.self).minimumIosVersion + + guard let buildNumberString = Bundle.main.infoDictionary?["CFBundleVersion"] as? String, + let buildNumber = Int(buildNumberString) + else { + Log.assertion(message: "no build number") + throw DomainError.unknown("no build number") + } + let minimumBuildNumber = minimumIosBuildNumber ?? buildNumber + + guard minimumBuildNumber <= buildNumber + else { + throw DomainError.AuthError.needUpdateAppVersion + } } ) } diff --git a/Projects/Domain/Error/Interface/Sources/DomainError.swift b/Projects/Domain/Error/Interface/Sources/DomainError.swift new file mode 100644 index 00000000..c3803c98 --- /dev/null +++ b/Projects/Domain/Error/Interface/Sources/DomainError.swift @@ -0,0 +1,16 @@ +// +// DomainError.swift +// DomainError +// +// Created by JongHoon on 9/11/24. +// + +import Foundation + +public enum DomainError: Error { + public enum AuthError: Error { + case needUpdateAppVersion + } + + case unknown(_ message: String? = nil) +} diff --git a/Projects/Domain/Error/Project.swift b/Projects/Domain/Error/Project.swift new file mode 100644 index 00000000..0c15299c --- /dev/null +++ b/Projects/Domain/Error/Project.swift @@ -0,0 +1,22 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Domain.name+ModulePath.Domain.Error.rawValue, + targets: [ + .domain( + interface: .Error, + factory: .init() + ), + .domain( + implements: .Error, + factory: .init( + dependencies: [ + .domain(interface: .Error) + ] + ) + ), + + ] +) diff --git a/Projects/Domain/Error/Sources/Source.swift b/Projects/Domain/Error/Sources/Source.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Domain/Error/Sources/Source.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift index 9970c8f1..85bff8fb 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift @@ -6,11 +6,16 @@ // import Foundation +import UIKit -import SharedDesignSystem -import CoreLoggerInterface import DomainProfile import DomainBottle +import DomainAuth +import DomainErrorInterface + +import CoreLoggerInterface + +import SharedDesignSystem import SharedUtilInterface import ComposableArchitecture @@ -30,16 +35,21 @@ public struct SandBeachFeature { public var isLoading: Bool = false public var isDisableIslandBottle: Bool = false + @Presents var destination: Destination.State? + public init() {} } - public enum Action: Equatable { + public enum Action: BindableAction { case onAppear case userStateFetchCompleted(userState: UserStateType, isDisableButton: Bool) case updateIsDisableBottle(isDisable: Bool) case writeButtonDidTapped case newBottleIslandDidTapped case bottleStorageIslandDidTapped + case needUpdateAppVersionErrorOccured + case updateAppVersion + case delegate(Delegate) public enum Delegate { @@ -47,16 +57,26 @@ public struct SandBeachFeature { case newBottleIslandDidTapped case bottleStorageIslandDidTapped } + + case alert(Alert) + public enum Alert: Equatable { + case needUpdateAppVersion + } + + case destination(PresentationAction) + case binding(BindingAction) } public var body: some ReducerOf { reducer + .ifLet(\.$destination, action: \.destination) } } // MARK: - init { extension SandBeachFeature { public init() { + @Dependency(\.authClient) var authClient @Dependency(\.profileClient) var profileClient @Dependency(\.bottleClient) var bottleClient @@ -78,9 +98,10 @@ extension SandBeachFeature { }) return .run { send in - let isExsit = try await profileClient.checkExistIntroduction() + async let _ = authClient.checkUpdateVersion() + async let isExsit = try await profileClient.checkExistIntroduction() // 자기소개 없는 상태 - if !isExsit { + if try await !isExsit { await send(.userStateFetchCompleted( userState: .noIntroduction, isDisableButton: true)) @@ -118,6 +139,12 @@ extension SandBeachFeature { } catch: { error, send in // TODO: 에러 핸들링 Log.error(error) + if let authError = error as? DomainError.AuthError { + switch authError { + case .needUpdateAppVersion: + await send(.needUpdateAppVersionErrorOccured) + } + } } case let .userStateFetchCompleted(userState, isDisableButton): @@ -137,6 +164,34 @@ extension SandBeachFeature { Log.debug("newBottleIslandDidTapped") return .send(.delegate(.newBottleIslandDidTapped)) + case .needUpdateAppVersionErrorOccured: + state.destination = .alert(.init( + title: { TextState("업데이트 안내") }, + actions: { + ButtonState( + action: .needUpdateAppVersion, + label: { TextState("업데이트 하기") } + ) + }, + message: { TextState("최적의 사용 환경을 위해\n최신 버전의 앱으로 업데이트 해주세요") } + )) + return .none + + case let .destination(.presented(.alert(alert))): + switch alert { + case .needUpdateAppVersion: + return .send(.updateAppVersion) + } + + case .updateAppVersion: + let appStoreURL = URL(string: Bundle.main.infoDictionary?["APP_STORE_URL"] as? String ?? "")! + UIApplication.shared.open(appStoreURL) + return .run { _ in + // TODO: Custom Alert 만들면 확인 눌러도 dismiss 되지 않도록 수정 + try await Task.sleep(nanoseconds: 3000_000_000) + exit(0) + } + default: return .none } @@ -144,3 +199,10 @@ extension SandBeachFeature { self.init(reducer: reducer) } } + +extension SandBeachFeature { + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + } +} diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift index e5e1d3cf..bcfea92b 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift @@ -12,7 +12,7 @@ import SharedDesignSystem import ComposableArchitecture public struct SandBeachView: View { - private let store: StoreOf + @Perception.Bindable private var store: StoreOf public init(store: StoreOf) { self.store = store @@ -64,6 +64,7 @@ public struct SandBeachView: View { } } } + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .onAppear { store.send(.onAppear) } diff --git a/Projects/Feature/Sources/App/AppFeature.swift b/Projects/Feature/Sources/App/AppFeature.swift index a5454124..aa3b6768 100644 --- a/Projects/Feature/Sources/App/AppFeature.swift +++ b/Projects/Feature/Sources/App/AppFeature.swift @@ -30,6 +30,7 @@ public struct AppFeature { case Login case MainTab case Onboarding + case Splash } @ObservableState @@ -39,6 +40,7 @@ public struct AppFeature { var mainTab: MainTabViewFeature.State? var login: LoginFeature.State? var onboarding: OnboardingFeature.State? + var splash: SplashFeature.State? public init() { self.appDelegate = .init() @@ -51,6 +53,7 @@ public struct AppFeature { case mainTab(MainTabViewFeature.Action) case login(LoginFeature.Action) case onboarding(OnboardingFeature.Action) + case splash(SplashFeature.Action) case checkUserLoginState case sceneDidActive @@ -79,6 +82,9 @@ public struct AppFeature { .ifLet(\.onboarding, action: \.onboarding) { OnboardingFeature() } + .ifLet(\.splash, action: \.splash) { + SplashFeature() + } } private func feature( @@ -126,6 +132,13 @@ public struct AppFeature { switch delegate { case let .fcmTokenDidRecevied(fcmToken): userClient.updateFcmToken(fcmToken: fcmToken) + return changeRoot(.Splash, state: &state) + } + + // Splash Delegate + case let .splash(.delegate(delegate)): + switch delegate { + case .initialCheckCompleted: return .send(.checkUserLoginState) } @@ -190,17 +203,25 @@ public struct AppFeature { case .Login: state.mainTab = nil state.onboarding = nil + state.splash = nil state.login = LoginFeature.State() userClient.updateLoginState(isLoggedIn: false) authClient.removeAllToken() case .MainTab: state.login = nil state.onboarding = nil + state.splash = nil state.mainTab = MainTabViewFeature.State() case .Onboarding: state.login = nil state.mainTab = nil + state.splash = nil state.onboarding = OnboardingFeature.State() + case .Splash: + state.login = nil + state.mainTab = nil + state.onboarding = nil + state.splash = SplashFeature.State() } return .none diff --git a/Projects/Feature/Sources/App/AppView.swift b/Projects/Feature/Sources/App/AppView.swift index 9a02380c..58c30c0e 100644 --- a/Projects/Feature/Sources/App/AppView.swift +++ b/Projects/Feature/Sources/App/AppView.swift @@ -30,8 +30,8 @@ public struct AppView: View { LoginView(store: loginStore) } else if let onboardingStore = store.scope(state: \.onboarding, action: \.onboarding) { OnboardingView(store: onboardingStore) - } else { - SplashView() + } else if let splashStore = store.scope(state: \.splash, action: \.splash) { + SplashView(store: splashStore) } } .onAppear { diff --git a/Projects/Feature/Sources/SplashView/SplashFeature.swift b/Projects/Feature/Sources/SplashView/SplashFeature.swift new file mode 100644 index 00000000..91fc85ef --- /dev/null +++ b/Projects/Feature/Sources/SplashView/SplashFeature.swift @@ -0,0 +1,114 @@ +// +// SplashFeature.swift +// Feature +// +// Created by JongHoon on 9/11/24. +// + +import Foundation +import UIKit + +import CoreLoggerInterface + +import DomainAuthInterface +import DomainErrorInterface + +import ComposableArchitecture + +@Reducer +public struct SplashFeature { + @Dependency(\.authClient) private var authClient + + @ObservableState + public struct State: Equatable { + @Presents var destination: Destination.State? + } + + public enum Action: BindableAction { + case onAppear + case needUpdateAppVersionErrorOccured + + case updateAppVersion + + case delegate(Delegate) + public enum Delegate { + case initialCheckCompleted + } + + case alert(Alert) + public enum Alert: Equatable { + case needUpdateAppVersion + } + + case destination(PresentationAction) + case binding(BindingAction) + } + + + public init() {} + + public var body: some ReducerOf { + Reduce(feature) + .ifLet(\.$destination, action: \.destination) + } + + private func feature( + state: inout State, + action: Action + ) -> EffectOf { + switch action { + case .onAppear: + return .run { send in + try await authClient.checkUpdateVersion() + await send(.delegate(.initialCheckCompleted)) + } catch: { error, send in + Log.error(error) + // TODO: Error handling + if let authError = error as? DomainError.AuthError { + switch authError { + case .needUpdateAppVersion: + await send(.needUpdateAppVersionErrorOccured) + } + } + } + + case .needUpdateAppVersionErrorOccured: + state.destination = .alert(.init( + title: { TextState("업데이트 안내") }, + actions: { + ButtonState( + action: .needUpdateAppVersion, + label: { TextState("업데이트 하기") } + ) + }, + message: { TextState("최적의 사용 환경을 위해\n최신 버전의 앱으로 업데이트 해주세요") } + )) + return .none + + case let .destination(.presented(.alert(alert))): + switch alert { + case .needUpdateAppVersion: + return .send(.updateAppVersion) + } + + case .updateAppVersion: + let appStoreURL = URL(string: Bundle.main.infoDictionary?["APP_STORE_URL"] as? String ?? "")! + UIApplication.shared.open(appStoreURL) + return .run { _ in + // TODO: Custom Alert 만들면 확인 눌러도 dismiss 되지 않도록 수정 + try await Task.sleep(nanoseconds: 3000_000_000) + exit(0) + } + + case .alert, .delegate, .destination, .binding: + return .none + } + } +} + +extension SplashFeature { + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + } +} diff --git a/Projects/Feature/Sources/SplashView/SplashView.swift b/Projects/Feature/Sources/SplashView/SplashView.swift index 402191db..52d19d52 100644 --- a/Projects/Feature/Sources/SplashView/SplashView.swift +++ b/Projects/Feature/Sources/SplashView/SplashView.swift @@ -6,17 +6,31 @@ // import SwiftUI + import SharedDesignSystem +import ComposableArchitecture + public struct SplashView: View { - public init() {} + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + public var body: some View { - ZStack { - ColorToken.container(.pressed).color - .ignoresSafeArea() - - Image.BottleImageSystem.illustraition(.splash).image + WithPerceptionTracking { + ZStack { + ColorToken.container(.pressed).color + .ignoresSafeArea() + + Image.BottleImageSystem.illustraition(.splash).image + } + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .ignoresSafeArea() + .task { + store.send(.onAppear) + } } - .ignoresSafeArea() } } diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift index 6fe35a23..8a366b22 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -25,6 +25,7 @@ public extension InfoPlist { "BASE_URL": "$(BASE_URL)", "WEB_VIEW_BASE_URL": "$(WEB_VIEW_BASE_URL)", "WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME": "$(WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME)", + "APP_STORE_URL": "$(APP_STORE_URL)", "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaotalk"], "CFBundleURLTypes": [ [ @@ -49,6 +50,7 @@ public extension InfoPlist { "BASE_URL": "$(BASE_URL)", "WEB_VIEW_BASE_URL": "$(WEB_VIEW_BASE_URL)", "WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME": "$(WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME)", + "APP_STORE_URL": "$(APP_STORE_URL)", "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaotalk"], "CFBundleURLTypes": [ [