diff --git a/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift b/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift index d5e6a153..4b6e57fb 100644 --- a/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift +++ b/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift @@ -12,15 +12,18 @@ struct LoginButton: View { @State private var logo: Image? @State private var text = "" @ScaledMetric(relativeTo: .body) private var height = CGFloat(22) + private let showsProgressView: Bool private let action: () -> Void init( logo: Image? = nil, text: String = "", + showsProgressView: Bool = false, action: @escaping () -> Void = {} ) { self._logo = State(initialValue: logo) self._text = State(initialValue: text) + self.showsProgressView = showsProgressView self.action = action } @@ -28,24 +31,30 @@ struct LoginButton: View { Button { action() } label: { - Text(text) - .foregroundStyle(Color.primary) - .font(.system(.body)) - .contentShape(.capsule) - .frame(width: 300, height: height + 16) - .overlay { - ZStack(alignment: .leading) { - Capsule() - .stroke(Color.gray, lineWidth: 1) - if let logo = logo { - logo - .resizable() - .scaledToFit() - .frame(width: height, height: height) - .padding(.leading) - } + ZStack { + Text(text) + .opacity(showsProgressView ? 0 : 1) + if showsProgressView { + ProgressView() + } + } + .foregroundStyle(Color.primary) + .font(.system(.body)) + .contentShape(.capsule) + .frame(width: 300, height: height + 16) + .overlay { + ZStack(alignment: .leading) { + Capsule() + .stroke(Color.gray, lineWidth: 1) + if let logo, !showsProgressView { + logo + .resizable() + .scaledToFit() + .frame(width: height, height: height) + .padding(.leading) } } + } } } } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index c6e26ab7..b565b1f6 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -67,10 +67,19 @@ struct TodoEditorView: View { Image(systemName: "info.circle") } } - ToolbarTrailingButton { - submit() + if store.isLoading { + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } else { + ToolbarTrailingButton { + submit() + } + .disabled(!store.isReadyToSubmit) } - .disabled(!store.isReadyToSubmit || store.isLoading) } .alert($store.scope(state: \.alert, action: \.alert)) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 8a56eca5..32ffbbd8 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -76,6 +76,7 @@ extension HomeFeature { trackAnalyticsEventUseCase.execute(.webPageCreate) let pages = try await fetchWebPagesUseCase.execute("") await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) + await send(.store(.setSheet(nil))) } catch { await send(.store(.setAlert(isPresented: true, type: .error))) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 04c6b94a..2edde907 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -253,7 +253,6 @@ private extension HomeFeature { Self.setAlert(&state, isPresented: true, type: .invalidURL) return .none } - state.sheet = nil Self.setAlert(&state, isPresented: false, type: nil) return addWebPageEffect(normalizedURL) case .deleteWebPage(let page): diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 40b122b1..6b516a53 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -38,11 +38,6 @@ struct HomeView: View { .alert($store.scope(state: \.alert, action: \.alert)) .sheet(item: $store.scope(state: \.sheet, action: \.sheet), content: sheetContent) .fullScreenCover(item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover), content: coverContent) - .overlay { - if store.isAppending { - LoadingView() - } - } } private var todoSection: some View { @@ -234,9 +229,18 @@ struct HomeView: View { .navigationTitle(Text(String(localized: "home_webpage_input_title"))) .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "home_add")) { - store.send(.view(.addWebPage)) + if store.isAppending { + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } else { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "home_add")) { + store.send(.view(.addWebPage)) + } } } } diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 196e6da3..b7c9a53e 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -14,6 +14,7 @@ struct LoginFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + var activeSignInProvider: AuthProvider? var loading = LoadingFeature.State() var isLoading: Bool { @@ -44,11 +45,15 @@ struct LoginFeature { case .alert: break case .tapSignInButton(let provider): + guard !state.isLoading else { return .none } + state.activeSignInProvider = provider return signInEffect(provider) case .signInFailed(let alertType): state.alert = Self.alertState(for: alertType) case .loading: - break + if !state.isLoading { + state.activeSignInProvider = nil + } } return .none } diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index 17e453dc..d75142df 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -25,38 +25,55 @@ struct LoginView: View { } var body: some View { - ZStack { - VStack { - Spacer() - Image("Primary") - .resizable() - .scaledToFit() - .frame(width: sceneWidth / 5) - Spacer() - VStack(spacing: 20) { - LoginButton(logo: Image("Google"), text: String(localized: "login_google_sign_in")) { - store.send(.tapSignInButton(.google)) - } - - LoginButton(logo: Image("Github"), text: String(localized: "login_github_sign_in")) { - store.send(.tapSignInButton(.github)) - } - - LoginButton(logo: Image("Apple"), text: String(localized: "login_apple_sign_in")) { - store.send(.tapSignInButton(.apple)) - } - } - .padding(.bottom, 30) - Text(String(localized: "login_terms_notice")) - .font(.caption2) - .foregroundStyle(Color.gray) - .multilineTextAlignment(.center) - .padding(.vertical) - } - if store.isLoading { - LoadingView() + VStack { + Spacer() + Image("Primary") + .resizable() + .scaledToFit() + .frame(width: sceneWidth / 5) + Spacer() + VStack(spacing: 20) { + signInButton( + provider: .google, + logo: Image("Google"), + text: String(localized: "login_google_sign_in") + ) + + signInButton( + provider: .github, + logo: Image("Github"), + text: String(localized: "login_github_sign_in") + ) + + signInButton( + provider: .apple, + logo: Image("Apple"), + text: String(localized: "login_apple_sign_in") + ) } + .padding(.bottom, 30) + Text(String(localized: "login_terms_notice")) + .font(.caption2) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.center) + .padding(.vertical) } .alert($store.scope(state: \.alert, action: \.alert)) } + + private func signInButton( + provider: AuthProvider, + logo: Image, + text: String + ) -> some View { + LoginButton( + logo: logo, + text: text, + showsProgressView: store.activeSignInProvider == provider + ) { + store.send(.tapSignInButton(provider)) + } + .disabled(store.isLoading) + .opacity(store.isLoading ? 0.5 : 1) + } } diff --git a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift index fbc492b1..941eaa9a 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift @@ -17,6 +17,7 @@ struct AccountFeature { var currentProvider: AuthProvider? var connectedProviders: [AuthProvider] = [] var disconnectedProviders: [AuthProvider] = [] + var activeLoadingProvider: AuthProvider? var loading = LoadingFeature.State() var isLoading: Bool { @@ -56,8 +57,12 @@ struct AccountFeature { case .onAppear: return fetchProvidersEffect() case .linkWithProvider(let provider): + guard !state.isLoading else { return .none } + state.activeLoadingProvider = provider return linkProviderEffect(provider) case .unlinkFromProvider(let provider): + guard !state.isLoading else { return .none } + state.activeLoadingProvider = provider return unlinkProviderEffect(provider) case .setAlert(let type): state.alert = Self.alertState(for: type) @@ -66,6 +71,10 @@ struct AccountFeature { state.connectedProviders = allProviders.filter { $0 != currentProvider } state.disconnectedProviders = AuthProvider.allCases .filter { !allProviders.contains($0) } + case .loading(.end): + if !state.isLoading { + state.activeLoadingProvider = nil + } case .loading: break } diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index 28c664de..c392d957 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -25,6 +25,7 @@ struct AccountView: View { let providers = AuthProvider.allCases.filter { $0 != store.currentProvider } ForEach(providers, id: \.self) { provider in let isConnected = store.connectedProviders.contains(provider) + let showProgressView = store.isLoading && store.activeLoadingProvider == provider HStack { providerContent(provider) Spacer() @@ -38,14 +39,21 @@ struct AccountView: View { Text(isConnected ? String(localized: "account_disconnect") : String(localized: "account_connect")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isConnected ? Color.red : .blue) - .clipShape(.capsule) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isConnected ? Color.red : .blue) + .clipShape(.capsule) } .buttonStyle(.plain) + .disabled(store.isLoading) + .opacity(showProgressView ? 0 : 1) + .overlay { + if showProgressView { + ProgressView() + } + } } } } @@ -55,11 +63,6 @@ struct AccountView: View { .navigationTitle(String(localized: "nav_account")) .onAppear { store.send(.onAppear) } .alert($store.scope(state: \.alert, action: \.alert)) - .overlay { - if store.isLoading { - LoadingView() - } - } } private func formattedProviderName(_ provider: AuthProvider) -> String { diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift index bd45e1f0..220dbfb9 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift @@ -12,12 +12,19 @@ import SwiftUI @Reducer struct PushNotificationSettingsFeature { + enum ActiveLoadingRow: Equatable { + case enable + case presetTime(hour: Int, minute: Int) + case customTime + } + @ObservableState struct State: Equatable { @Presents var alert: AlertState? @Presents var timePicker: TimePickerState? var pushNotificationEnable = false var viewPushNotificationTime = Date() + var activeLoadingRow: ActiveLoadingRow? var loading = LoadingFeature.State() var isLoading: Bool { @@ -46,6 +53,7 @@ struct PushNotificationSettingsFeature { case setAlert case tapCustomTime case selectPresetTime(Date) + case clearActiveLoadingRow case loading(LoadingFeature.Action) enum TimePicker: BindableAction, Equatable { @@ -68,6 +76,7 @@ struct PushNotificationSettingsFeature { case .alert: break case .binding(\.pushNotificationEnable): + state.activeLoadingRow = .enable return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) case .binding(\.viewPushNotificationTime): let time = state.viewPushNotificationTime @@ -80,12 +89,16 @@ struct PushNotificationSettingsFeature { state.timePicker = nil case .timePicker(.presented(.tapDoneButton)): guard let time = state.timePicker?.time else { break } - state.timePicker = nil state.viewPushNotificationTime = time - return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) + state.activeLoadingRow = .customTime + return updatePushNotificationSettingsEffect( + settings: Self.settings(from: state), + dismissesTimePickerOnSuccess: true + ) case .timePicker: break case .fetchSettings: + state.activeLoadingRow = .enable return fetchPushNotificationSettingsEffect() case .applyFetchedSettings(let settings): state.pushNotificationEnable = settings.isEnabled @@ -101,7 +114,10 @@ struct PushNotificationSettingsFeature { case .selectPresetTime(let date): state.viewPushNotificationTime = date state.timePicker?.time = date + state.activeLoadingRow = Self.activeLoadingRow(for: date) return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) + case .clearActiveLoadingRow: + state.activeLoadingRow = nil case .loading: break } @@ -156,6 +172,15 @@ private enum UpdatePushSettingsUseCaseKey: DependencyKey { } } +extension PushNotificationSettingsFeature { + static func activeLoadingRow(for date: Date) -> ActiveLoadingRow? { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + guard let hour = components.hour, + let minute = components.minute else { return nil } + return .presetTime(hour: hour, minute: minute) + } +} + private extension PushNotificationSettingsFeature { func fetchPushNotificationSettingsEffect() -> Effect { .run { [fetchPushSettingsUseCase] send in @@ -164,21 +189,31 @@ private extension PushNotificationSettingsFeature { let settings = try await fetchPushSettingsUseCase.execute() await send(.applyFetchedSettings(settings)) await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) } catch { await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) await send(.setAlert) } } } - func updatePushNotificationSettingsEffect(settings: PushNotificationSettings) -> Effect { + func updatePushNotificationSettingsEffect( + settings: PushNotificationSettings, + dismissesTimePickerOnSuccess: Bool = false + ) -> Effect { .run { [updatePushSettingsUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { try await updatePushSettingsUseCase.execute(settings) await send(.loading(.end(target: .default, mode: .delayed))) + if dismissesTimePickerOnSuccess { + await send(.timePicker(.dismiss)) + } + await send(.clearActiveLoadingRow) } catch { await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) await send(.setAlert) await send(.fetchSettings) } diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index 88d7a481..043a9d85 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -14,21 +14,34 @@ struct PushNotificationSettingsView: View { var body: some View { List { Section(content: { - Toggle(isOn: $store.pushNotificationEnable) { + HStack { Text(String(localized: "push_settings_enable")) + Spacer() + if store.isLoading && store.activeLoadingRow == .enable { + ProgressView() + } else { + Toggle("", isOn: $store.pushNotificationEnable) + .labelsHidden() + .tint(.blue) + .disabled(store.activeLoadingRow != nil) + } } - .tint(.blue) }, footer: { Text(String(localized: "push_settings_footer")) }) Section { ForEach([9, 15, 18, 21], id: \.self) { hour in if let date = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: Date()) { + let loadingRow = PushNotificationSettingsFeature.activeLoadingRow(for: date) HStack { Text(formattedTimeString(date)) Spacer() - if store.pushNotificationHour == hour && - store.pushNotificationMinute == 0 { + if let loadingRow, + store.isLoading && store.activeLoadingRow == loadingRow { + ProgressView() + } else if store.activeLoadingRow != loadingRow + && store.pushNotificationHour == hour + && store.pushNotificationMinute == 0 { Image(systemName: "checkmark") .foregroundStyle(Color.blue) } @@ -50,16 +63,18 @@ struct PushNotificationSettingsView: View { .contentShape(Rectangle()) .onTapGesture { store.send(.tapCustomTime) } } - .disabled(!store.pushNotificationEnable) + .disabled(!store.pushNotificationEnable || store.activeLoadingRow != nil) .opacity(store.pushNotificationEnable ? 1.0 : 0.2) } .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_push_settings")) - .overlay { if store.isLoading { LoadingView() } } .onAppear { store.send(.fetchSettings) } .alert($store.scope(state: \.alert, action: \.alert)) - .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { store in - TimePickerView(store: store) + .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { timePickerStore in + TimePickerView( + store: timePickerStore, + showsProgressView: store.isLoading && store.activeLoadingRow == .customTime + ) } } @@ -73,6 +88,7 @@ private struct TimePickerView: View { PushNotificationSettingsFeature.TimePickerState, PushNotificationSettingsFeature.Action.TimePicker > + let showsProgressView: Bool var body: some View { NavigationStack { @@ -89,8 +105,17 @@ private struct TimePickerView: View { ToolbarLeadingButton { store.send(.tapCloseButton) } - ToolbarTrailingButton { - store.send(.tapDoneButton) + if showsProgressView { + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } else { + ToolbarTrailingButton { + store.send(.tapDoneButton) + } } } .background( diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift index bf46c4db..25baa805 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift @@ -18,12 +18,19 @@ struct SettingsFeature { case systemTheme } + enum ActiveLoadingRow: Equatable { + case removeCache + case signOut + case deleteAuth + } + @ObservableState struct State: Equatable { @Presents var alert: AlertState? var theme: SystemTheme = .automatic var dirSize: Int64 = 0 var isNetworkConnected = true + var activeLoadingRow: ActiveLoadingRow? var loading = LoadingFeature.State() var alertType: Action.AlertType? var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @@ -78,14 +85,17 @@ struct SettingsFeature { case .alert(.presented(.tapDeleteAuthButton)): state.alert = nil state.alertType = nil + state.activeLoadingRow = .deleteAuth return deleteAuthEffect() case .alert(.presented(.tapSignOutButton)): state.alert = nil state.alertType = nil + state.activeLoadingRow = .signOut return signOutEffect() case .alert(.presented(.confirmRemoveCache)): state.alert = nil state.alertType = nil + state.activeLoadingRow = .removeCache return clearWebPageImageDirectoryEffect() case .alert(.dismiss): state.alert = nil @@ -113,6 +123,10 @@ struct SettingsFeature { case .tapRemoveCacheButton: state.alert = Self.alertState(for: .removeCache) state.alertType = .removeCache + case .loading(.end): + if !state.isLoading { + state.activeLoadingRow = nil + } case .loading: break } @@ -263,11 +277,14 @@ private extension SettingsFeature { func clearWebPageImageDirectoryEffect() -> Effect { .run { [clearWebPageImageDirectoryUseCase, fetchWebPageImageDirSizeUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) do { try await clearWebPageImageDirectoryUseCase.execute() let dirSize = await fetchWebPageImageDirSizeUseCase.execute() await send(.setDirSize(dirSize)) + await send(.loading(.end(target: .default, mode: .delayed))) } catch { + await send(.loading(.end(target: .default, mode: .delayed))) await send(.setAlert(.error)) } } @@ -278,7 +295,7 @@ private extension SettingsFeature { await send(.loading(.begin(target: .default, mode: .delayed))) do { try await deleteAuthUseCase.execute() - await send(.loading(.end(target: .default, mode: .delayed))) + // 유스케이스 완료가 LoginView 전환 완료를 의미하지 않으므로 화면이 교체될 때까지 로딩을 유지한다. } catch { await send(.loading(.end(target: .default, mode: .delayed))) await send(.setAlert(.error)) @@ -291,7 +308,7 @@ private extension SettingsFeature { await send(.loading(.begin(target: .default, mode: .delayed))) do { try await signOutUseCase.execute() - await send(.loading(.end(target: .default, mode: .delayed))) + // 유스케이스 완료가 LoginView 전환 완료를 의미하지 않으므로 화면이 교체될 때까지 로딩을 유지한다. } catch { await send(.loading(.end(target: .default, mode: .delayed))) await send(.setAlert(.error)) diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsView.swift b/Application/DevLogPresentation/Sources/Settings/SettingsView.swift index 9a60fcdd..f217e659 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsView.swift @@ -44,11 +44,16 @@ struct SettingsView: View { Text(String(localized: "settings_clear_temp_data")) .foregroundStyle(dirSize == 0 ? Color.secondary : .primary) Spacer() - Text(formatFileSize(bytes: dirSize)) - .foregroundStyle(Color.secondary.opacity(dirSize == 0 ? 0 : 1)) + if store.activeLoadingRow == .removeCache { + ProgressView() + .tint(.secondary) + } else { + Text(formatFileSize(bytes: dirSize)) + .foregroundStyle(Color.secondary.opacity(dirSize == 0 ? 0 : 1)) + } } } - .disabled(dirSize == 0) + .disabled(dirSize == 0 || store.isLoading) } Section { @@ -88,34 +93,42 @@ struct SettingsView: View { Text(String(localized: "settings_account")) } .disabled(!connected) - Button(role: .destructive, action: { + Button { store.send(.setAlert(.signOut)) - }) { - Text(String(localized: "settings_sign_out")) + } label: { + HStack { + Text(String(localized: "settings_sign_out")) + .foregroundStyle(.red) + Spacer() + if store.activeLoadingRow == .signOut { + ProgressView() + } + } } - .disabled(!connected) + .disabled(!connected || store.isLoading) } HStack { Spacer() - Button(role: .destructive, action: { + Button { store.send(.setAlert(.deleteAuth)) - }) { - Text(String(localized: "settings_delete_account")) - .font(.headline) + } label: { + if store.activeLoadingRow == .deleteAuth { + ProgressView() + .tint(.red) + } else { + Text(String(localized: "settings_delete_account")) + .foregroundStyle(.red) + .font(.headline) + } } - .disabled(!connected) + .disabled(!connected || store.isLoading) Spacer() } } .navigationTitle(String(localized: "nav_settings")) .navigationBarTitleDisplayMode(.inline) .alert($store.scope(state: \.alert, action: \.alert)) - .overlay { - if store.isLoading { - LoadingView() - } - } .onAppear { store.send(.updateDirSize) } diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift index 96d21f2b..c95a509d 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift @@ -101,6 +101,7 @@ func verifyHomeAddWebPage( fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy, trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy ) async throws { + await adapter.setPresentation(.contentPicker, true) await adapter.updateWebPageURLInput("openai.com") await adapter.addWebPage() @@ -116,9 +117,28 @@ func verifyHomeAddWebPage( "https://openai.com", "https://developer.apple.com" ]) + #expect(!adapter.showContentPicker) #expect(!adapter.showAlert) } +@MainActor +func verifyHomeAddWebPageFailureKeepsSheet( + adapter: HomeStoreTestAdapter, + addWebPageUseCaseSpy: AddWebPageUseCaseSpy +) async throws { + await adapter.setPresentation(.contentPicker, true) + await adapter.updateWebPageURLInput("openai.com") + await adapter.addWebPage() + + await waitUntil { + addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + && adapter.showAlert + } + + #expect(adapter.showContentPicker) + #expect(adapter.alertType == .error) +} + struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift index 383269f0..a7da6f02 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift @@ -74,6 +74,22 @@ struct HomeFeatureTests { ) } + @Test("HomeFeature addWebPage 실패는 입력 시트를 유지한다") + func HomeFeature_addWebPage_실패는_입력_시트를_유지한다() async throws { + let context = makeHomeAddWebPageContext() + context.addWebPageUseCaseSpy.error = HomeTestError.failure + let adapter = HomeStoreTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy + ) + + try await verifyHomeAddWebPageFailureKeepsSheet( + adapter: adapter, + addWebPageUseCaseSpy: context.addWebPageUseCaseSpy + ) + } + @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 삭제 유스케이스가 호출된다") func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_삭제_유스케이스가_호출된다() async throws { let context = makeHomeDeleteContext() @@ -143,3 +159,7 @@ struct HomeFeatureTests { #expect(!adapter.isNetworkConnected) } } + +private enum HomeTestError: Error { + case failure +} diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index d9657866..c221979e 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -40,6 +40,7 @@ struct LoginFeatureTests { } #expect(driver.isLoading) + #expect(driver.activeSignInProvider == .google) spy.resume() @@ -48,6 +49,7 @@ struct LoginFeatureTests { } #expect(driver.isLoading) + #expect(driver.activeSignInProvider == .google) } @Test("로그인 실패 후에도 로딩 상태가 꺼진다") @@ -72,6 +74,7 @@ struct LoginFeatureTests { } #expect(!driver.isLoading) + #expect(driver.activeSignInProvider == nil) } @Test("이메일을 가져오지 못하면 이메일 없음 알림을 표시한다") @@ -153,6 +156,10 @@ private struct LoginTestDriver { feature.state.isLoading } + var activeSignInProvider: AuthProvider? { + feature.state.activeSignInProvider + } + var showAlert: Bool { hasAlert } diff --git a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift index 42108b12..1ecf0e46 100644 --- a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift @@ -110,6 +110,7 @@ struct AccountFeatureTests { } #expect(driver.isLoading) + #expect(driver.activeLoadingProvider == .github) linkSpy.resume() @@ -252,6 +253,10 @@ private struct AccountTestDriver { feature.state.isLoading } + var activeLoadingProvider: AuthProvider? { + feature.state.activeLoadingProvider + } + var alert: AlertState? { feature.state.alert } diff --git a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift index 63e43082..8f091504 100644 --- a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift @@ -5,6 +5,8 @@ // Created by opfic on 6/12/26. // +// swiftlint:disable file_length + import Testing import ComposableArchitecture import Foundation @@ -143,14 +145,84 @@ struct PushNotificationSettingsFeatureTests { await adapter.receiveDelayedLoading() #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .enable) fetchSpy.resume() await adapter.drainReceivedActions() #expect(!adapter.isLoading) + #expect(adapter.activeLoadingRow == nil) #expect(adapter.pushNotificationHour == 9) } + @Test("프리셋 시간 업데이트가 지연되면 해당 시간 row에 로딩 상태를 표시한다") + func 프리셋_시간_업데이트가_지연되면_해당_시간_row에_로딩_상태를_표시한다() async { + let clock = TestClock() + let updateSpy = UpdatePushSettingsUseCaseSpy() + updateSpy.shouldSuspend = true + let adapter = PushNotificationSettingsStoreTestAdapter( + updateUseCase: updateSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + let date = makeDate(hour: 15, minute: 0) + + await adapter.selectPresetTime(date) + + #expect(updateSpy.executeCallCount == 1) + #expect(adapter.activeLoadingRow == .presetTime(hour: 15, minute: 0)) + #expect(!adapter.isLoading) + + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading() + + #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .presetTime(hour: 15, minute: 0)) + + updateSpy.resume() + await adapter.drainReceivedActions() + + #expect(!adapter.isLoading) + #expect(adapter.activeLoadingRow == nil) + } + + @Test("커스텀 시간 업데이트가 지연되면 시트를 유지하고 Done 버튼 로딩 상태를 표시한다") + func 커스텀_시간_업데이트가_지연되면_시트를_유지하고_Done_버튼_로딩_상태를_표시한다() async { + let clock = TestClock() + let updateSpy = UpdatePushSettingsUseCaseSpy() + updateSpy.shouldSuspend = true + let adapter = PushNotificationSettingsStoreTestAdapter( + updateUseCase: updateSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + let date = makeDate(hour: 10, minute: 35) + + await adapter.setShowTimePicker(true) + await adapter.setPushNotificationTime(sheet: date) + await adapter.confirmUpdate() + + #expect(adapter.showTimePicker) + #expect(adapter.activeLoadingRow == .customTime) + #expect(!adapter.isLoading) + + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading() + + #expect(adapter.showTimePicker) + #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .customTime) + + updateSpy.resume() + await adapter.drainReceivedActions() + + #expect(!adapter.showTimePicker) + #expect(!adapter.isLoading) + #expect(adapter.activeLoadingRow == nil) + } + @Test("푸시 설정 조회에 실패하면 공통 에러 알림을 표시한다") func 푸시_설정_조회에_실패하면_공통_에러_알림을_표시한다() async { let fetchSpy = FetchPushSettingsUseCaseSpy() @@ -193,6 +265,7 @@ private struct PushNotificationSettingsStoreTestAdapter { var sheetPushNotificationTime: Date { store.state.timePicker?.time ?? store.state.viewPushNotificationTime } var showTimePicker: Bool { store.state.timePicker != nil } var isLoading: Bool { store.state.isLoading } + var activeLoadingRow: PushNotificationSettingsFeature.ActiveLoadingRow? { store.state.activeLoadingRow } var sheetHeight: CGFloat { store.state.timePicker?.height ?? .pi } var alert: AlertState? { store.state.alert } var pushNotificationHour: Int { store.state.pushNotificationHour } @@ -272,10 +345,10 @@ private struct PushNotificationSettingsStoreTestAdapter { func confirmUpdate() async { let time = store.state.timePicker?.time await store.send(.timePicker(.presented(.tapDoneButton))) { - $0.timePicker = nil if let time { $0.viewPushNotificationTime = time } + $0.activeLoadingRow = .customTime } await drainReceivedActions() } @@ -349,15 +422,40 @@ private final class FetchPushSettingsUseCaseSpy: FetchPushSettingsUseCase { private final class UpdatePushSettingsUseCaseSpy: UpdatePushSettingsUseCase { var error: Error? + var shouldSuspend = false private(set) var executeCallCount = 0 + private var continuation: CheckedContinuation? + private var shouldResume = false func execute(_: PushNotificationSettings) async throws { executeCallCount += 1 + + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + if let error { self.error = nil throw error } } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } } private enum PushNotificationSettingsTestError: Error { diff --git a/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift index 07fabd7c..301a09c0 100644 --- a/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift @@ -90,6 +90,7 @@ struct SettingsFeatureTests { #expect(!adapter.showAlert) #expect(adapter.dirSize == 0) + #expect(adapter.activeLoadingRow == nil) } @Test("캐시 삭제에 실패하면 공통 에러 알림을 표시한다") @@ -104,10 +105,11 @@ struct SettingsFeatureTests { #expect(adapter.showAlert) #expect(adapter.alertTitle == String(localized: "common_error_title")) #expect(adapter.alertMessage == String(localized: "common_error_message")) + #expect(adapter.activeLoadingRow == nil) } - @Test("로그아웃 작업이 지연되면 로딩 상태를 표시하고 완료되면 해제한다") - func 로그아웃_작업이_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async { + @Test("로그아웃 성공 후에도 LoginView 전환 전까지 로딩 상태를 유지한다") + func 로그아웃_성공_후에도_LoginView_전환_전까지_로딩_상태를_유지한다() async { let signOutSpy = SignOutUseCaseSpy() signOutSpy.shouldSuspend = true let adapter = SettingsStoreTestAdapter(signOutUseCase: signOutSpy) @@ -119,11 +121,25 @@ struct SettingsFeatureTests { await adapter.advanceDelayedLoading() #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .signOut) signOutSpy.resume() await adapter.drainReceivedActions() - #expect(!adapter.isLoading) + #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .signOut) + } + + @Test("로그아웃 실패 시 로딩 row 상태를 해제한다") + func 로그아웃_실패_시_로딩_row_상태를_해제한다() async { + let signOutSpy = SignOutUseCaseSpy() + signOutSpy.error = SettingsTestError.failure + let adapter = SettingsStoreTestAdapter(signOutUseCase: signOutSpy) + + await adapter.tapSignOutButton() + + #expect(adapter.showAlert) + #expect(adapter.activeLoadingRow == nil) } @Test("회원 탈퇴 실패 시 공통 에러 알림을 표시한다") @@ -137,6 +153,7 @@ struct SettingsFeatureTests { #expect(deleteSpy.executeCallCount == 1) #expect(adapter.showAlert) #expect(adapter.alertTitle == String(localized: "common_error_title")) + #expect(adapter.activeLoadingRow == nil) } } @@ -156,6 +173,7 @@ private struct SettingsStoreTestAdapter { var dirSize: Int64 { store.state.dirSize } var isNetworkConnected: Bool { store.state.isNetworkConnected } var isLoading: Bool { store.state.isLoading } + var activeLoadingRow: SettingsFeature.ActiveLoadingRow? { store.state.activeLoadingRow } var showAlert: Bool { store.state.alert != nil } var alertTitle: String { guard let alert = store.state.alert else { return "" } @@ -221,6 +239,7 @@ private struct SettingsStoreTestAdapter { await store.send(.alert(.presented(.confirmRemoveCache))) { $0.alert = nil $0.alertType = nil + $0.activeLoadingRow = .removeCache } await drainReceivedActions() } @@ -233,6 +252,7 @@ private struct SettingsStoreTestAdapter { await store.send(.alert(.presented(.tapSignOutButton))) { $0.alert = nil $0.alertType = nil + $0.activeLoadingRow = .signOut } await drainReceivedActions() } @@ -245,6 +265,7 @@ private struct SettingsStoreTestAdapter { await store.send(.alert(.presented(.tapDeleteAuthButton))) { $0.alert = nil $0.alertType = nil + $0.activeLoadingRow = .deleteAuth } await drainReceivedActions() } diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 2172c7ac..42b23d07 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -155,10 +155,14 @@ final class UpdateTodoCategoryPreferencesUseCaseSpy: UpdateTodoCategoryPreferenc } final class AddWebPageUseCaseSpy: AddWebPageUseCase { + var error: Error? private(set) var calledUrlStrings: [String] = [] func execute(_ urlString: String) async throws { calledUrlStrings.append(urlString) + if let error { + throw error + } } }