diff --git a/Application/DevLogApp/Sources/App/DevLogApp.swift b/Application/DevLogApp/Sources/App/DevLogApp.swift index 4e8e5c36..3b434735 100644 --- a/Application/DevLogApp/Sources/App/DevLogApp.swift +++ b/Application/DevLogApp/Sources/App/DevLogApp.swift @@ -30,7 +30,6 @@ struct DevLogApp: App { networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - signInUseCase: container.resolve(SignInUseCase.self), widgetURLTab: { MainTab(widgetURL: $0) }, windowEvent: windowEvent, pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(), diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 4ff8d5c5..710870fe 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -13,17 +13,13 @@ import Foundation struct LoginFeature { @ObservableState struct State: Equatable { + @Presents var alert: AlertState? var isLoading = false - var showAlert = false - var alertType: AlertType? - var alertTitle = "" - var alertMessage = "" } enum Action { - case setAlert(Bool, AlertType? = nil) + case alert(PresentationAction) case tapSignInButton(AuthProvider) - case signInSucceeded case signInFailed(AlertType) case signInCancelled } @@ -38,14 +34,13 @@ struct LoginFeature { var body: some ReducerOf { Reduce { state, action in switch action { - case .setAlert(let isPresented, let alertType): - setAlert(&state, isPresented: isPresented, alertType: alertType) + case .alert: + break case .tapSignInButton(let provider): state.isLoading = true return .run { [signInUseCase] send in do { try await signInUseCase.execute(provider) - await send(.signInSucceeded) } catch { if error.isSocialLoginCancelled { await send(.signInCancelled) @@ -54,14 +49,15 @@ struct LoginFeature { await send(.signInFailed(alertType(for: error))) } } - case .signInSucceeded, .signInCancelled: + case .signInCancelled: state.isLoading = false case .signInFailed(let alertType): state.isLoading = false - setAlert(&state, isPresented: true, alertType: alertType) + state.alert = alertState(for: alertType) } return .none } + .ifLet(\.$alert, action: \.alert) } } @@ -95,24 +91,28 @@ extension DependencyValues { } private extension LoginFeature { - func setAlert( - _ state: inout State, - isPresented: Bool, - alertType: AlertType? - ) { + func alertState(for alertType: AlertType) -> AlertState { + let title: String + let message: String + switch alertType { case .emailUnavailable: - state.alertTitle = String(localized: "login_alert_email_unavailable_title") - state.alertMessage = String(localized: "login_alert_email_unavailable_message") + title = String(localized: "login_alert_email_unavailable_title") + message = String(localized: "login_alert_email_unavailable_message") case .error: - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - case .none: - state.alertTitle = "" - state.alertMessage = "" + 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) } - state.showAlert = isPresented - state.alertType = alertType } func alertType(for error: Error) -> AlertType { diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index 55998367..a447820d 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -7,11 +7,22 @@ import SwiftUI import ComposableArchitecture +import DevLogDomain struct LoginView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.sceneWidth) var sceneWidth - let store: StoreOf + @State private var store: StoreOf + + init(signInUseCase: SignInUseCase) { + self._store = State(initialValue: Store( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.signInUseCase = .live(signInUseCase) + }) + } var body: some View { ZStack { @@ -46,13 +57,6 @@ struct LoginView: View { LoadingView() } } - .alert(store.alertTitle, isPresented: Binding( - get: { store.showAlert }, - set: { store.send(.setAlert($0)) } - )) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(store.alertMessage) - } + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index 530a8ce7..21374dd2 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -16,7 +16,6 @@ public struct RootView: View { @State var viewModel: RootViewModel @State private var selectedRoute: Route? @State private var selectedMainTab = MainTab.home - private let loginStore: StoreOf private let widgetURLTab: (URL) -> MainTab? private let windowEvent: TodoEditorWindowEvent private let pushNotificationTodoIdPublisher: AnyPublisher @@ -27,7 +26,6 @@ public struct RootView: View { networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, systemThemeUseCase: ObserveSystemThemeUseCase, trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, - signInUseCase: SignInUseCase, widgetURLTab: @escaping (URL) -> MainTab?, windowEvent: TodoEditorWindowEvent, pushNotificationTodoIdPublisher: AnyPublisher, @@ -39,13 +37,6 @@ public struct RootView: View { systemThemeUseCase: systemThemeUseCase, trackAnalyticsEventUseCase: trackAnalyticsEventUseCase )) - self.loginStore = Store( - initialState: LoginFeature.State() - ) { - LoginFeature() - } withDependencies: { - $0.signInUseCase = .live(signInUseCase) - } self.widgetURLTab = widgetURLTab self.windowEvent = windowEvent self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher @@ -63,7 +54,7 @@ public struct RootView: View { selectedTab: $selectedMainTab ) } else { - LoginView(store: loginStore) + LoginView(signInUseCase: container.resolve(SignInUseCase.self)) } } } diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index 324c135f..0b10261a 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -27,8 +27,8 @@ struct LoginFeatureTests { #expect(spy.calledProviders == [.github]) } - @Test("로그인 요청 중에는 로딩 상태가 켜지고 요청이 끝나면 꺼진다") - func 로그인_요청_중에는_로딩_상태가_켜지고_요청이_끝나면_꺼진다() async { + @Test("로그인 성공 후에도 메인 화면 전환 전까지 로딩 상태를 유지한다") + func 로그인_성공_후에도_메인_화면_전환_전까지_로딩_상태를_유지한다() async { let spy = SignInUseCaseSpy() spy.shouldSuspend = true let driver = LoginTestDriver(useCase: spy) @@ -44,10 +44,10 @@ struct LoginFeatureTests { spy.resume() await waitUntil { - !driver.isLoading + spy.successfulProviders == [.google] } - #expect(!driver.isLoading) + #expect(driver.isLoading) } @Test("로그인 실패 후에도 로딩 상태가 꺼진다") @@ -68,7 +68,7 @@ struct LoginFeatureTests { spy.resume() await waitUntil { - !driver.isLoading && driver.showAlert + !driver.isLoading && driver.hasAlert } #expect(!driver.isLoading) @@ -83,12 +83,13 @@ struct LoginFeatureTests { driver.tapSignInButton(.google) await waitUntil { - driver.showAlert + driver.hasAlert } - #expect(driver.alertKind == .emailUnavailable) - #expect(driver.alertTitle == String(localized: "login_alert_email_unavailable_title")) - #expect(driver.alertMessage == String(localized: "login_alert_email_unavailable_message")) + #expect(driver.alert == expectedAlert( + title: String(localized: "login_alert_email_unavailable_title"), + message: String(localized: "login_alert_email_unavailable_message") + )) } @Test("일반 로그인 에러가 발생하면 공통 에러 알림을 표시한다") @@ -100,12 +101,13 @@ struct LoginFeatureTests { driver.tapSignInButton(.apple) await waitUntil { - driver.showAlert + driver.hasAlert } - #expect(driver.alertKind == .error) - #expect(driver.alertTitle == String(localized: "common_error_title")) - #expect(driver.alertMessage == String(localized: "common_error_message")) + #expect(driver.alert == expectedAlert( + title: String(localized: "common_error_title"), + message: String(localized: "common_error_message") + )) } @Test("소셜 로그인 취소 에러가 발생하면 알림을 표시하지 않는다") @@ -121,9 +123,7 @@ struct LoginFeatureTests { } #expect(!driver.showAlert) - #expect(driver.alertKind == nil) - #expect(driver.alertTitle.isEmpty) - #expect(driver.alertMessage.isEmpty) + #expect(driver.alert == nil) } @Test("알림을 닫으면 알림 상태와 문구가 초기화된다") @@ -135,23 +135,16 @@ struct LoginFeatureTests { driver.tapSignInButton(.google) await waitUntil { - driver.showAlert + driver.hasAlert } - driver.setAlert(false) + driver.dismissAlert() #expect(!driver.showAlert) - #expect(driver.alertKind == nil) - #expect(driver.alertTitle.isEmpty) - #expect(driver.alertMessage.isEmpty) + #expect(driver.alert == nil) } } -private enum LoginAlertKind: Equatable { - case emailUnavailable - case error -} - @MainActor private struct LoginTestDriver { private let feature: StoreOf @@ -161,26 +154,15 @@ private struct LoginTestDriver { } var showAlert: Bool { - feature.state.showAlert + hasAlert } - var alertKind: LoginAlertKind? { - switch feature.state.alertType { - case .emailUnavailable: - return .emailUnavailable - case .error: - return .error - case .none: - return nil - } + var hasAlert: Bool { + alert != nil } - var alertTitle: String { - feature.state.alertTitle - } - - var alertMessage: String { - feature.state.alertMessage + var alert: AlertState? { + feature.state.alert } init(useCase: SignInUseCase) { @@ -197,7 +179,22 @@ private struct LoginTestDriver { feature.send(.tapSignInButton(provider)) } - func setAlert(_ isPresented: Bool) { - feature.send(.setAlert(isPresented)) + func dismissAlert() { + feature.send(.alert(.dismiss)) + } +} + +private func expectedAlert( + title: String, + message: String +) -> AlertState { + AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) } } diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 4d6bf319..15bf5fc5 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -54,6 +54,7 @@ final class SignInUseCaseSpy: SignInUseCase { var error: Error? var shouldSuspend = false private(set) var calledProviders: [AuthProvider] = [] + private(set) var successfulProviders = [AuthProvider]() private var continuation: CheckedContinuation? private var shouldResume = false @@ -74,6 +75,8 @@ final class SignInUseCaseSpy: SignInUseCase { if let error { throw error } + + successfulProviders.append(provider) } func resume() {