diff --git a/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift index 3f49cb0e..bd534495 100644 --- a/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift @@ -97,14 +97,14 @@ struct CategoryManageFeature { case tapEditUserCategory(TodoCategoryItem) case tapDeleteUserCategory(TodoCategoryItem) case tapDoneButton + case setCategorySheet(CategorySheetState?) enum Alert: Equatable { case confirmDeleteUserCategory(TodoCategoryItem) } - enum CategorySheet: Equatable { - case setCategoryName(String) - case setCategoryColor(String) + enum CategorySheet: BindableAction, Equatable { + case binding(BindingAction) case tapCloseButton case tapRandomColorButton case tapSaveButton @@ -124,14 +124,6 @@ struct CategoryManageFeature { state.categorySheet = nil case .categorySheet(.presented(.tapCloseButton)): state.categorySheet = nil - case .categorySheet(.presented(.setCategoryName(let name))): - state.categorySheet?.category.name = String(name.prefix(20)) - case .categorySheet(.presented(.setCategoryColor(let colorHex))): - state.categorySheet?.category.colorHex = colorHex - case .categorySheet(.presented(.tapRandomColorButton)): - if let randomHexValue = Color.randomValue.hexValue { - state.categorySheet?.category.colorHex = randomHexValue - } case .categorySheet(.presented(.tapSaveButton)): if var item = state.categorySheet?.todoCategoryItem { if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { @@ -175,10 +167,39 @@ struct CategoryManageFeature { } case .tapDoneButton: break + case .setCategorySheet(let sheet): + state.categorySheet = sheet } return .none } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$categorySheet, action: \.categorySheet) { + CategoryManageSheetFeature() + } + } +} + +private struct CategoryManageSheetFeature: Reducer { + typealias State = CategoryManageFeature.CategorySheetState + typealias Action = CategoryManageFeature.Action.CategorySheet + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.category.name): + state.category.name = String(state.category.name.prefix(20)) + case .binding: + break + case .tapRandomColorButton: + if let randomHexValue = Color.randomValue.hexValue { + state.category.colorHex = randomHexValue + } + case .tapCloseButton, .tapSaveButton: + break + } + return .none + } } } diff --git a/Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift index 076128f1..8cb56c88 100644 --- a/Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift +++ b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift @@ -66,8 +66,8 @@ struct CategoryManageView: View { .navigationTitle(String(localized: "nav_todo_manage")) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() - .sheet(item: $store.scope(state: \.categorySheet, action: \.categorySheet)) { store in - CategoryManageSheet(store: store) + .sheet(item: $store.scope(state: \.categorySheet, action: \.categorySheet)) { sheetStore in + sheetContent(sheetStore) } .alert($store.scope(state: \.alert, action: \.alert)) .toolbar { @@ -91,10 +91,17 @@ struct CategoryManageView: View { } .presentationDragIndicator(.visible) } + + @ViewBuilder + private func sheetContent( + _ sheetStore: Store + ) -> some View { + CategoryManageSheet(store: sheetStore) + } } private struct CategoryManageSheet: View { - let store: Store + @Bindable var store: Store var body: some View { NavigationStack { @@ -103,10 +110,7 @@ private struct CategoryManageSheet: View { HStack(spacing: 8) { TextField( "", - text: Binding( - get: { store.category.name }, - set: { store.send(.setCategoryName($0)) } - ), + text: $store.category.name, prompt: Text(store.placeholder).foregroundStyle(.secondary) ) .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) @@ -119,21 +123,14 @@ private struct CategoryManageSheet: View { } Section { - let color = Color(hexString: store.category.colorHex) ?? .randomValue - ColorPicker(selection: Binding( - get: { color }, - set: { - guard let hexValue = $0.hexValue else { return } - store.send(.setCategoryColor(hexValue)) - } - ), supportsOpacity: false) { + ColorPicker(selection: $store.category.colorHex.colorValue, supportsOpacity: false) { Text(store.category.colorHex.isEmpty ? "#" : store.category.colorHex) .overlay(alignment: .bottom) { Rectangle() .frame(height: 1) .offset(y: 1) } - .foregroundStyle(color) + .foregroundStyle(store.category.colorHex.colorValue) .onTapGesture { store.send(.tapRandomColorButton) } @@ -160,3 +157,14 @@ private struct CategoryManageSheet: View { } } } + +private extension String { + var colorValue: Color { + get { Color(hexString: self) ?? .randomValue } + set { + if let hexValue = newValue.hexValue { + self = hexValue + } + } + } +} diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 710870fe..8aaf018b 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -29,7 +29,7 @@ struct LoginFeature { case error } - @Dependency(SignInUseCaseDependency.self) var signInUseCase + @Dependency(\.signInUseCase) var signInUseCase var body: some ReducerOf { Reduce { state, action in @@ -61,32 +61,20 @@ struct LoginFeature { } } -struct SignInUseCaseDependency { - var execute: (AuthProvider) async throws -> Void - - init(execute: @escaping (AuthProvider) async throws -> Void) { - self.execute = execute +extension DependencyValues { + var signInUseCase: SignInUseCase { + get { self[SignInUseCaseKey.self] } + set { self[SignInUseCaseKey.self] = newValue } } } -extension SignInUseCaseDependency: DependencyKey { - static let liveValue = Self { _ in - preconditionFailure("SignInUseCaseDependency must be provided.") - } - - static let testValue = liveValue - - static func live(_ signInUseCase: SignInUseCase) -> SignInUseCaseDependency { - Self { - try await signInUseCase.execute($0) - } +private enum SignInUseCaseKey: DependencyKey { + static var liveValue: SignInUseCase { + preconditionFailure("SignInUseCase must be provided.") } -} -extension DependencyValues { - var signInUseCase: SignInUseCaseDependency { - get { self[SignInUseCaseDependency.self] } - set { self[SignInUseCaseDependency.self] = newValue } + static var testValue: SignInUseCase { + liveValue } } diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index a447820d..17e453dc 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -20,7 +20,7 @@ struct LoginView: View { ) { LoginFeature() } withDependencies: { - $0.signInUseCase = .live(signInUseCase) + $0.signInUseCase = signInUseCase }) } diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index d23ea979..c4509d3a 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -375,7 +375,7 @@ struct MainView: View { viewModel: profileViewCoordinator.makePushNotificationSettingsViewModel() ) case .account: - AccountView(viewModel: profileViewCoordinator.makeAccountViewModel()) + AccountView(store: profileViewCoordinator.makeAccountStore()) } } } diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index da88acd1..c17f4e2a 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -172,7 +172,7 @@ struct ProfileView: View { case .pushNotification: PushNotificationSettingsView(viewModel: coordinator.makePushNotificationSettingsViewModel()) case .account: - AccountView(viewModel: coordinator.makeAccountViewModel()) + AccountView(store: coordinator.makeAccountStore()) } } diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift index 897a38b5..034b25ce 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift @@ -44,12 +44,14 @@ final class ProfileViewCoordinator { viewModel.send(.fetchData) } - func makeAccountViewModel() -> AccountViewModel { - AccountViewModel( - fetchProvidersUseCase: container.resolve(FetchAuthProvidersUseCase.self), - linkProviderUseCase: container.resolve(LinkAuthProviderUseCase.self), - unlinkProviderUseCase: container.resolve(UnlinkAuthProviderUseCase.self) - ) + func makeAccountStore() -> StoreOf { + Store(initialState: AccountFeature.State()) { + AccountFeature() + } withDependencies: { + $0.fetchAuthProvidersUseCase = self.container.resolve(FetchAuthProvidersUseCase.self) + $0.linkAuthProviderUseCase = self.container.resolve(LinkAuthProviderUseCase.self) + $0.unlinkAuthProviderUseCase = self.container.resolve(UnlinkAuthProviderUseCase.self) + } } func makePushNotificationSettingsViewModel() -> PushNotificationSettingsViewModel { diff --git a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift new file mode 100644 index 00000000..25c49172 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift @@ -0,0 +1,227 @@ +// +// AccountFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/11/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation + +@Reducer +struct AccountFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var currentProvider: AuthProvider? + var connectedProviders: [AuthProvider] = [] + var disconnectedProviders: [AuthProvider] = [] + var loading = LoadingFeature.State() + + var isLoading: Bool { + loading.isLoading + } + } + + enum Action { + case alert(PresentationAction) + case onAppear + case linkWithProvider(AuthProvider) + case unlinkFromProvider(AuthProvider) + case setAlert(AlertType) + case setProviders(currentProvider: AuthProvider?, allProviders: [AuthProvider]) + case loading(LoadingFeature.Action) + } + + enum AlertType: Equatable { + case linkEmailNotFound + case linkEmailMismatch + case linkCredentialAlreadyInUse + case error + } + + @Dependency(\.fetchAuthProvidersUseCase) var fetchProvidersUseCase + @Dependency(\.linkAuthProviderUseCase) var linkProviderUseCase + @Dependency(\.unlinkAuthProviderUseCase) var unlinkProviderUseCase + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + Reduce { state, action in + switch action { + case .alert: + break + case .onAppear: + return fetchProvidersEffect() + case .linkWithProvider(let provider): + return linkProviderEffect(provider) + case .unlinkFromProvider(let provider): + return unlinkProviderEffect(provider) + case .setAlert(let type): + state.alert = alertState(for: type) + case .setProviders(let currentProvider, let allProviders): + state.currentProvider = currentProvider + state.connectedProviders = allProviders.filter { $0 != currentProvider } + state.disconnectedProviders = AuthProvider.allCases + .filter { !allProviders.contains($0) } + case .loading: + break + } + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension DependencyValues { + var fetchAuthProvidersUseCase: FetchAuthProvidersUseCase { + get { self[FetchAuthProvidersUseCaseKey.self] } + set { self[FetchAuthProvidersUseCaseKey.self] = newValue } + } + + var linkAuthProviderUseCase: LinkAuthProviderUseCase { + get { self[LinkAuthProviderUseCaseKey.self] } + set { self[LinkAuthProviderUseCaseKey.self] = newValue } + } + + var unlinkAuthProviderUseCase: UnlinkAuthProviderUseCase { + get { self[UnlinkAuthProviderUseCaseKey.self] } + set { self[UnlinkAuthProviderUseCaseKey.self] = newValue } + } +} + +private enum FetchAuthProvidersUseCaseKey: DependencyKey { + static var liveValue: FetchAuthProvidersUseCase { + preconditionFailure("FetchAuthProvidersUseCase must be provided.") + } + + static var testValue: FetchAuthProvidersUseCase { + liveValue + } +} + +private enum LinkAuthProviderUseCaseKey: DependencyKey { + static var liveValue: LinkAuthProviderUseCase { + preconditionFailure("LinkAuthProviderUseCase must be provided.") + } + + static var testValue: LinkAuthProviderUseCase { + liveValue + } +} + +private enum UnlinkAuthProviderUseCaseKey: DependencyKey { + static var liveValue: UnlinkAuthProviderUseCase { + preconditionFailure("UnlinkAuthProviderUseCase must be provided.") + } + + static var testValue: UnlinkAuthProviderUseCase { + liveValue + } +} + +private extension AccountFeature { + func fetchProvidersEffect() -> Effect { + .run { [fetchProvidersUseCase] send in + do { + let providers = try await fetchProvidersUseCase.execute() + await send(.setProviders( + currentProvider: providers.currentProvider, + allProviders: providers.allProviders + )) + } catch { + await send(.setAlert(.error)) + } + } + } + + func linkProviderEffect(_ provider: AuthProvider) -> Effect { + .run { [fetchProvidersUseCase, linkProviderUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + try await linkProviderUseCase.execute(provider) + await ToastPresenter.present(message: String(localized: "account_toast_link_success")) + let providers = try await fetchProvidersUseCase.execute() + await send(.setProviders( + currentProvider: providers.currentProvider, + allProviders: providers.allProviders + )) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + + if error.isSocialLoginCancelled { return } + + await send(.setAlert(linkAlertType(for: error))) + } + } + } + + func unlinkProviderEffect(_ provider: AuthProvider) -> Effect { + .run { [fetchProvidersUseCase, unlinkProviderUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + try await unlinkProviderUseCase.execute(provider) + await ToastPresenter.present(message: String(localized: "account_toast_unlink_success")) + let providers = try await fetchProvidersUseCase.execute() + await send(.setProviders( + currentProvider: providers.currentProvider, + allProviders: providers.allProviders + )) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.setAlert(.error)) + } + } + } +} + +private func linkAlertType(for error: Error) -> AccountFeature.AlertType { + guard let authError = error as? AuthError else { + return .error + } + + switch authError { + case .linkEmailNotFound: + return .linkEmailNotFound + case .linkEmailMismatch: + return .linkEmailMismatch + case .linkCredentialAlreadyInUse: + return .linkCredentialAlreadyInUse + case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider: + return .error + } +} + +private func alertState(for type: AccountFeature.AlertType) -> AlertState { + let title: String + let message: String + + switch type { + case .linkEmailNotFound: + title = String(localized: "account_alert_email_unavailable_title") + message = String(localized: "account_alert_email_unavailable_message") + case .linkEmailMismatch: + title = String(localized: "account_alert_cannot_link_title") + message = String(localized: "account_alert_cannot_link_message") + case .linkCredentialAlreadyInUse: + title = String(localized: "account_alert_already_linked_title") + message = String(localized: "account_alert_already_linked_message") + case .error: + title = String(localized: "common_error_title") + message = String(localized: "common_error_message") + } + + return AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) + } +} diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index 29eb8883..ac9efede 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -6,32 +6,33 @@ // import SwiftUI +import ComposableArchitecture import DevLogDomain struct AccountView: View { - @State var viewModel: AccountViewModel + @Bindable var store: StoreOf var body: some View { List { Section(String(localized: "account_current_section")) { HStack { - if let provider = viewModel.state.currentProvider { + if let provider = store.currentProvider { providerContent(provider) } } } Section(String(localized: "account_social_section")) { - let providers = AuthProvider.allCases.filter { $0 != viewModel.state.currentProvider } + let providers = AuthProvider.allCases.filter { $0 != store.currentProvider } ForEach(providers, id: \.self) { provider in - let isConnected = viewModel.state.connectedProviders.contains(provider) + let isConnected = store.connectedProviders.contains(provider) HStack { providerContent(provider) Spacer() Button { if isConnected { - viewModel.send(.unlinkFromProvider(provider)) + store.send(.unlinkFromProvider(provider)) } else { - viewModel.send(.linkWithProvider(provider)) + store.send(.linkWithProvider(provider)) } } label: { Text(isConnected @@ -52,19 +53,10 @@ struct AccountView: View { .scrollDisabled(true) .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_account")) - .onAppear { - viewModel.send(.onAppear) - } - .alert(viewModel.state.alertTitle, isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert(isPresented: $0)) } - )) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) - } + .onAppear { store.send(.onAppear) } + .alert($store.scope(state: \.alert, action: \.alert)) .overlay { - if viewModel.state.isLoading { + if store.isLoading { LoadingView() } } diff --git a/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift b/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift deleted file mode 100644 index 140897fa..00000000 --- a/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// AccountViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 2/12/26. -// - -import Foundation -import DevLogDomain - -@Observable -final class AccountViewModel: StorePattern { - struct State: Equatable { - var currentProvider: AuthProvider? - var connectedProviders: [AuthProvider] = [] - var disconnectedProviders: [AuthProvider] = [] - var showAlert: Bool = false - var alertTitle: String = "" - var alertType: AlertType? - var alertMessage: String = "" - var isLoading: Bool = false - } - - enum Action { - case onAppear - case linkWithProvider(AuthProvider) - case unlinkFromProvider(AuthProvider) - case setAlert(isPresented: Bool, type: AlertType? = nil) - case setLoading(Bool) - case updateProviders(currentProvider: AuthProvider?, allProviders: [AuthProvider]) - } - - enum SideEffect { - case fetch - case link(AuthProvider) - case unlink(AuthProvider) - } - - enum AlertType { - case linkEmailNotFound - case linkEmailMismatch - case linkCredentialAlreadyInUse - case error - } - - private(set) var state: State = .init() - private let fetchProvidersUseCase: FetchAuthProvidersUseCase - private let linkProviderUseCase: LinkAuthProviderUseCase - private let unlinkProviderUseCase: UnlinkAuthProviderUseCase - private let loadingState = LoadingState() - - init( - fetchProvidersUseCase: FetchAuthProvidersUseCase, - linkProviderUseCase: LinkAuthProviderUseCase, - unlinkProviderUseCase: UnlinkAuthProviderUseCase - ) { - self.fetchProvidersUseCase = fetchProvidersUseCase - self.linkProviderUseCase = linkProviderUseCase - self.unlinkProviderUseCase = unlinkProviderUseCase - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .onAppear: - effects = [.fetch] - case .linkWithProvider(let value): - effects = [.link(value)] - case .unlinkFromProvider(let value): - effects = [.unlink(value)] - case .setAlert(let presented, let type): - setAlert(&state, isPresented: presented, type: type) - case .setLoading(let value): - state.isLoading = value - case .updateProviders(let currentProvider, let allProviders): - state.currentProvider = currentProvider - state.connectedProviders = allProviders.filter { $0 != currentProvider } - state.disconnectedProviders = AuthProvider.allCases - .filter { !allProviders.contains($0) } - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetch: - Task { - do { - let (currentProvider, allProviders) = try await fetchProvidersUseCase.execute() - send(.updateProviders(currentProvider: currentProvider, allProviders: allProviders)) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .link(let provider): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - try await linkProviderUseCase.execute(provider) - ToastPresenter.present(message: String(localized: "account_toast_link_success")) - - let (currentProvider, allProviders) = try await fetchProvidersUseCase.execute() - send(.updateProviders(currentProvider: currentProvider, allProviders: allProviders)) - } catch { - if error.isSocialLoginCancelled { return } - send(.setAlert(isPresented: true, type: linkAlertType(for: error))) - } - } - case .unlink(let provider): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - try await unlinkProviderUseCase.execute(provider) - ToastPresenter.present(message: String(localized: "account_toast_unlink_success")) - - let (currentProvider, allProviders) = try await fetchProvidersUseCase.execute() - send(.updateProviders(currentProvider: currentProvider, allProviders: allProviders)) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - } - } -} - -private extension AccountViewModel { - func linkAlertType(for error: Error) -> AlertType { - guard let authError = error as? AuthError else { - return .error - } - - switch authError { - case .linkEmailNotFound: - return .linkEmailNotFound - case .linkEmailMismatch: - return .linkEmailMismatch - case .linkCredentialAlreadyInUse: - return .linkCredentialAlreadyInUse - case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider: - return .error - } - } - - func setAlert(_ state: inout State, isPresented: Bool, type: AlertType?) { - switch type { - case .linkEmailNotFound: - state.alertTitle = String(localized: "account_alert_email_unavailable_title") - state.alertMessage = String(localized: "account_alert_email_unavailable_message") - case .linkEmailMismatch: - state.alertTitle = String(localized: "account_alert_cannot_link_title") - state.alertMessage = String(localized: "account_alert_cannot_link_message") - case .linkCredentialAlreadyInUse: - state.alertTitle = String(localized: "account_alert_already_linked_title") - state.alertMessage = String(localized: "account_alert_already_linked_message") - case .error: - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - case .none: - state.alertTitle = "" - state.alertMessage = "" - } - state.showAlert = isPresented - state.alertType = type - } - - private func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - private func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} diff --git a/Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift index 31409206..2fff5f09 100644 --- a/Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift @@ -137,6 +137,33 @@ struct CategoryManageFeatureTests { #expect(driver.preferences == [item]) #expect(driver.alert == nil) } + + @Test("시트 상태를 액션에 맞게 변경한다") + func 시트_상태를_액션에_맞게_변경한다() { + let category = UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + let sheet = CategoryManageFeature.CategorySheetState( + category: category, + preferences: [] + ) + let driver = CategoryManageTestDriver(preferences: []) + + driver.setCategorySheet(sheet) + + #expect(driver.categorySheet == sheet) + + driver.dismissCategorySheet() + + #expect(driver.categorySheet == nil) + + driver.setCategorySheet(sheet) + driver.tapCloseButton() + + #expect(driver.categorySheet == nil) + } } @MainActor @@ -183,18 +210,30 @@ private struct CategoryManageTestDriver { feature.send(.tapDeleteUserCategory(item)) } + func setCategorySheet(_ sheet: CategoryManageFeature.CategorySheetState?) { + feature.send(.setCategorySheet(sheet)) + } + + func dismissCategorySheet() { + feature.send(.categorySheet(.dismiss)) + } + func setCategoryName(_ name: String) { - feature.send(.categorySheet(.presented(.setCategoryName(name)))) + feature.send(.categorySheet(.presented(.binding(.set(\.category.name, name))))) } func setCategoryColor(_ colorHex: String) { - feature.send(.categorySheet(.presented(.setCategoryColor(colorHex)))) + feature.send(.categorySheet(.presented(.binding(.set(\.category.colorHex, colorHex))))) } func tapSaveButton() { feature.send(.categorySheet(.presented(.tapSaveButton))) } + func tapCloseButton() { + feature.send(.categorySheet(.presented(.tapCloseButton))) + } + func confirmDeleteUserCategory(_ item: TodoCategoryItem) { feature.send(.alert(.presented(.confirmDeleteUserCategory(item)))) } diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index 0b10261a..3157f33f 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -171,7 +171,7 @@ private struct LoginTestDriver { ) { LoginFeature() } withDependencies: { - $0.signInUseCase = .live(useCase) + $0.signInUseCase = useCase } } diff --git a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift new file mode 100644 index 00000000..970a64ad --- /dev/null +++ b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift @@ -0,0 +1,391 @@ +// +// AccountFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/11/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct AccountFeatureTests { + @Test("화면이 나타나면 인증 제공자 목록을 가져와 상태에 반영한다") + func 화면이_나타나면_인증_제공자_목록을_가져와_상태에_반영한다() async { + let fetchSpy = FetchAuthProvidersUseCaseSpy( + currentProvider: .google, + allProviders: [.google, .github] + ) + let driver = AccountTestDriver(fetchUseCase: fetchSpy) + + driver.onAppear() + + await waitUntil { + driver.currentProvider == .google + } + + #expect(fetchSpy.executeCallCount == 1) + #expect(driver.connectedProviders == [.github]) + #expect(driver.disconnectedProviders == [.apple]) + } + + @Test("연동에 성공하면 선택한 제공자를 연동하고 제공자 목록을 다시 가져온다") + func 연동에_성공하면_선택한_제공자를_연동하고_제공자_목록을_다시_가져온다() async { + let fetchSpy = FetchAuthProvidersUseCaseSpy( + currentProvider: .google, + allProviders: [.google, .github] + ) + let linkSpy = LinkAuthProviderUseCaseSpy() + let driver = AccountTestDriver( + fetchUseCase: fetchSpy, + linkUseCase: linkSpy + ) + + driver.linkWithProvider(.github) + + await waitUntil { + linkSpy.providers == [.github] && fetchSpy.executeCallCount == 1 + } + + #expect(driver.currentProvider == .google) + #expect(driver.connectedProviders == [.github]) + #expect(driver.disconnectedProviders == [.apple]) + } + + @Test("연동 해제에 성공하면 선택한 제공자를 해제하고 제공자 목록을 다시 가져온다") + func 연동_해제에_성공하면_선택한_제공자를_해제하고_제공자_목록을_다시_가져온다() async { + let fetchSpy = FetchAuthProvidersUseCaseSpy( + currentProvider: .google, + allProviders: [.google] + ) + let unlinkSpy = UnlinkAuthProviderUseCaseSpy() + let driver = AccountTestDriver( + fetchUseCase: fetchSpy, + unlinkUseCase: unlinkSpy + ) + + driver.unlinkFromProvider(.github) + + await waitUntil { + unlinkSpy.providers == [.github] && fetchSpy.executeCallCount == 1 + } + + #expect(driver.currentProvider == .google) + #expect(driver.connectedProviders.isEmpty) + #expect(driver.disconnectedProviders == [.apple, .github]) + } + + @Test("연동 작업이 지연되면 로딩 상태를 표시하고 완료되면 해제한다") + func 연동_작업이_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async { + let clock = TestClock() + let fetchSpy = FetchAuthProvidersUseCaseSpy( + currentProvider: .google, + allProviders: [.google, .github] + ) + let linkSpy = LinkAuthProviderUseCaseSpy() + linkSpy.shouldSuspend = true + let driver = AccountTestDriver( + fetchUseCase: fetchSpy, + linkUseCase: linkSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + driver.linkWithProvider(.github) + + await waitUntil { + linkSpy.providers == [.github] + } + + #expect(!driver.isLoading) + + await clock.advance(by: .milliseconds(300)) + + await waitUntil { + driver.isLoading + } + + #expect(driver.isLoading) + + linkSpy.resume() + + await waitUntil { + !driver.isLoading && fetchSpy.executeCallCount == 1 + } + + #expect(!driver.isLoading) + } + + @Test("인증 제공자 조회에 실패하면 공통 에러 알림을 표시한다") + func 인증_제공자_조회에_실패하면_공통_에러_알림을_표시한다() async { + let fetchSpy = FetchAuthProvidersUseCaseSpy() + fetchSpy.error = AccountTestError.failure + let driver = AccountTestDriver(fetchUseCase: fetchSpy) + + driver.onAppear() + + await waitUntil { + driver.alert != nil + } + + #expect(driver.alert == expectedAlert( + title: String(localized: "common_error_title"), + message: String(localized: "common_error_message") + )) + } + + @Test("연동 실패 에러 유형에 맞는 알림을 표시한다") + func 연동_실패_에러_유형에_맞는_알림을_표시한다() async { + let scenarios = [ + AccountLinkFailureScenario( + error: AuthError.linkEmailNotFound, + title: String(localized: "account_alert_email_unavailable_title"), + message: String(localized: "account_alert_email_unavailable_message") + ), + AccountLinkFailureScenario( + error: AuthError.linkEmailMismatch, + title: String(localized: "account_alert_cannot_link_title"), + message: String(localized: "account_alert_cannot_link_message") + ), + AccountLinkFailureScenario( + error: AuthError.linkCredentialAlreadyInUse, + title: String(localized: "account_alert_already_linked_title"), + message: String(localized: "account_alert_already_linked_message") + ), + AccountLinkFailureScenario( + error: AccountTestError.failure, + title: String(localized: "common_error_title"), + message: String(localized: "common_error_message") + ) + ] + + for scenario in scenarios { + let linkSpy = LinkAuthProviderUseCaseSpy() + linkSpy.error = scenario.error + let driver = AccountTestDriver(linkUseCase: linkSpy) + + driver.linkWithProvider(.github) + + await waitUntil { + driver.alert != nil + } + + #expect(driver.alert == expectedAlert( + title: scenario.title, + message: scenario.message + )) + } + } + + @Test("소셜 로그인 취소 에러로 연동이 실패하면 알림을 표시하지 않는다") + func 소셜_로그인_취소_에러로_연동이_실패하면_알림을_표시하지_않는다() async { + let linkSpy = LinkAuthProviderUseCaseSpy() + linkSpy.error = NSError(domain: "com.google.GIDSignIn", code: -5) + let driver = AccountTestDriver(linkUseCase: linkSpy) + + driver.linkWithProvider(.google) + + await waitUntil { + linkSpy.providers == [.google] && !driver.isLoading + } + + #expect(driver.alert == nil) + } + + @Test("연동 해제 실패 시 공통 에러 알림을 표시한다") + func 연동_해제_실패_시_공통_에러_알림을_표시한다() async { + let unlinkSpy = UnlinkAuthProviderUseCaseSpy() + unlinkSpy.error = AccountTestError.failure + let driver = AccountTestDriver(unlinkUseCase: unlinkSpy) + + driver.unlinkFromProvider(.github) + + await waitUntil { + driver.alert != nil + } + + #expect(driver.alert == expectedAlert( + title: String(localized: "common_error_title"), + message: String(localized: "common_error_message") + )) + } + + @Test("알림을 닫으면 알림 상태가 초기화된다") + func 알림을_닫으면_알림_상태가_초기화된다() async { + let fetchSpy = FetchAuthProvidersUseCaseSpy() + fetchSpy.error = AccountTestError.failure + let driver = AccountTestDriver(fetchUseCase: fetchSpy) + + driver.onAppear() + + await waitUntil { + driver.alert != nil + } + + driver.dismissAlert() + + #expect(driver.alert == nil) + } +} + +@MainActor +private struct AccountTestDriver { + private let feature: StoreOf + + var currentProvider: AuthProvider? { + feature.state.currentProvider + } + + var connectedProviders: [AuthProvider] { + feature.state.connectedProviders + } + + var disconnectedProviders: [AuthProvider] { + feature.state.disconnectedProviders + } + + var isLoading: Bool { + feature.state.isLoading + } + + var alert: AlertState? { + feature.state.alert + } + + init( + fetchUseCase: FetchAuthProvidersUseCase = FetchAuthProvidersUseCaseSpy(), + linkUseCase: LinkAuthProviderUseCase = LinkAuthProviderUseCaseSpy(), + unlinkUseCase: UnlinkAuthProviderUseCase = UnlinkAuthProviderUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + feature = Store(initialState: AccountFeature.State()) { + AccountFeature() + } withDependencies: { + $0.fetchAuthProvidersUseCase = fetchUseCase + $0.linkAuthProviderUseCase = linkUseCase + $0.unlinkAuthProviderUseCase = unlinkUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + } + + func onAppear() { + feature.send(.onAppear) + } + + func linkWithProvider(_ provider: AuthProvider) { + feature.send(.linkWithProvider(provider)) + } + + func unlinkFromProvider(_ provider: AuthProvider) { + feature.send(.unlinkFromProvider(provider)) + } + + func dismissAlert() { + feature.send(.alert(.dismiss)) + } +} + +private struct AccountLinkFailureScenario { + let error: Error + let title: String + let message: String +} + +private final class FetchAuthProvidersUseCaseSpy: FetchAuthProvidersUseCase { + var currentProvider: AuthProvider? + var allProviders: [AuthProvider] + var error: Error? + private(set) var executeCallCount = 0 + + init( + currentProvider: AuthProvider? = nil, + allProviders: [AuthProvider] = [] + ) { + self.currentProvider = currentProvider + self.allProviders = allProviders + } + + func execute() async throws -> (currentProvider: AuthProvider?, allProviders: [AuthProvider]) { + executeCallCount += 1 + + if let error { + throw error + } + + return (currentProvider, allProviders) + } +} + +private final class LinkAuthProviderUseCaseSpy: LinkAuthProviderUseCase { + var error: Error? + var shouldSuspend = false + private(set) var providers = [AuthProvider]() + private var continuation: CheckedContinuation? + private var shouldResume = false + + func execute(_ provider: AuthProvider) async throws { + providers.append(provider) + + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + + if let error { + throw error + } + } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } +} + +private final class UnlinkAuthProviderUseCaseSpy: UnlinkAuthProviderUseCase { + var error: Error? + private(set) var providers = [AuthProvider]() + + func execute(_ provider: AuthProvider) async throws { + providers.append(provider) + + if let error { + throw error + } + } +} + +private enum AccountTestError: Error { + case failure +} + +private func expectedAlert( + title: String, + message: String +) -> AlertState { + AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) + } +}