From b6fd22814cfca5b4ba4469a55fd3a40ed90d84cf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:09:45 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20HomeFeature=201=EC=B0=A8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Dependencies.swift | 101 ++++++ .../Home/Home/HomeFeature+Effects.swift | 219 ++++++++++++ .../Sources/Home/Home/HomeFeature.swift | 217 ++++++++++++ .../Sources/Home/Home/HomeView.swift | 116 ++++--- .../Home/Home/HomeViewCoordinator.swift | 35 +- .../Home/HomeFeatureTestAssertions.swift | 272 +++++++++++++++ .../Tests/Home/HomeFeatureTestSupport.swift | 315 ++++++++++++++++++ .../Tests/Home/HomeFeatureTests.swift | 190 +++++++++++ 8 files changed, 1406 insertions(+), 59 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift create mode 100644 Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift create mode 100644 Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift create mode 100644 Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift create mode 100644 Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift create mode 100644 Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift new file mode 100644 index 00000000..a346296d --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift @@ -0,0 +1,101 @@ +// +// HomeFeature+Dependencies.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import ComposableArchitecture +import DevLogDomain + +extension DependencyValues { + var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase { + get { self[HomeUpdatePreferencesUseCaseKey.self] } + set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue } + } + + var homeAddWebPageUseCase: AddWebPageUseCase { + get { self[HomeAddWebPageUseCaseKey.self] } + set { self[HomeAddWebPageUseCaseKey.self] = newValue } + } + + var homeDeleteWebPageUseCase: DeleteWebPageUseCase { + get { self[HomeDeleteWebPageUseCaseKey.self] } + set { self[HomeDeleteWebPageUseCaseKey.self] = newValue } + } + + var homeUndoDeleteWebPageUseCase: UndoDeleteWebPageUseCase { + get { self[HomeUndoDeleteWebPageUseCaseKey.self] } + set { self[HomeUndoDeleteWebPageUseCaseKey.self] = newValue } + } + + var homeFetchTodosUseCase: FetchTodosUseCase { + get { self[HomeFetchTodosUseCaseKey.self] } + set { self[HomeFetchTodosUseCaseKey.self] = newValue } + } + + var homeFetchWebPagesUseCase: FetchWebPagesUseCase { + get { self[HomeFetchWebPagesUseCaseKey.self] } + set { self[HomeFetchWebPagesUseCaseKey.self] = newValue } + } +} + +private enum HomeUpdatePreferencesUseCaseKey: DependencyKey { + static var liveValue: UpdateTodoCategoryPreferencesUseCase { + preconditionFailure("UpdateTodoCategoryPreferencesUseCase must be provided.") + } + + static var testValue: UpdateTodoCategoryPreferencesUseCase { + liveValue + } +} + +private enum HomeAddWebPageUseCaseKey: DependencyKey { + static var liveValue: AddWebPageUseCase { + preconditionFailure("AddWebPageUseCase must be provided.") + } + + static var testValue: AddWebPageUseCase { + liveValue + } +} + +private enum HomeDeleteWebPageUseCaseKey: DependencyKey { + static var liveValue: DeleteWebPageUseCase { + preconditionFailure("DeleteWebPageUseCase must be provided.") + } + + static var testValue: DeleteWebPageUseCase { + liveValue + } +} + +private enum HomeUndoDeleteWebPageUseCaseKey: DependencyKey { + static var liveValue: UndoDeleteWebPageUseCase { + preconditionFailure("UndoDeleteWebPageUseCase must be provided.") + } + + static var testValue: UndoDeleteWebPageUseCase { + liveValue + } +} + +private enum HomeFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +private enum HomeFetchWebPagesUseCaseKey: DependencyKey { + static var liveValue: FetchWebPagesUseCase { + preconditionFailure("FetchWebPagesUseCase must be provided.") + } + + static var testValue: FetchWebPagesUseCase { + liveValue + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift new file mode 100644 index 00000000..2af81815 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -0,0 +1,219 @@ +// +// HomeFeature+Effects.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +extension HomeFeature { + private enum CancelID: Hashable { + case delayedModal(ModalType) + case networkConnectivity + } + + func observeNetworkConnectivityEffect() -> Effect { + .publisher { [networkConnectivityUseCase] in + networkConnectivityUseCase.observe() + .receive(on: DispatchQueue.main) + .map(Action.networkStatusChanged) + } + .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) + } + + func fetchTodoCategoryPreferencesEffect() -> Effect { + .run { [fetchPreferencesUseCase] send in + await send(.loading(.begin(target: LoadingTarget.preferences.target, mode: .immediate))) + do { + let preferences = try await fetchPreferencesUseCase.execute() + await send(.store(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:))))) + } catch { + await send(.setAlert(isPresented: true, type: .error)) + } + await send(.loading(.end(target: LoadingTarget.preferences.target, mode: .immediate))) + } + } + + func fetchRecentTodosEffect() -> Effect { + .run { [fetchTodosUseCase] send in + await send(.loading(.begin(target: LoadingTarget.recentTodos.target, mode: .immediate))) + do { + let page = try await fetchRecentTodos(fetchTodosUseCase: fetchTodosUseCase) + let items = page.items + .filter { $0.createdAt != $0.updatedAt } + .prefix(5) + .compactMap(RecentTodoItem.init(from:)) + await send(.store(.updateRecentTodos(Array(items)))) + } catch { + await send(.setAlert(isPresented: true, type: .error)) + } + await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate))) + } + } + + func fetchWebPagesEffect() -> Effect { + .run { [fetchWebPagesUseCase] send in + await send(.loading(.begin(target: LoadingTarget.webPage.target, mode: .immediate))) + do { + let pages = try await fetchWebPagesUseCase.execute("") + await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) + } catch { + await send(.setAlert(isPresented: true, type: .error)) + } + await send(.loading(.end(target: LoadingTarget.webPage.target, mode: .immediate))) + } + } + + func addWebPageEffect(_ urlString: String) -> Effect { + .run { [addWebPageUseCase, fetchWebPagesUseCase, trackAnalyticsEventUseCase] send in + await send(.loading(.begin(target: LoadingTarget.overlay.target, mode: .delayed))) + do { + try await addWebPageUseCase.execute(urlString) + trackAnalyticsEventUseCase?.execute(.webPageCreate) + let pages = try await fetchWebPagesUseCase.execute("") + await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) + } catch { + await send(.setAlert(isPresented: true, type: .error)) + } + await send(.loading(.end(target: LoadingTarget.overlay.target, mode: .delayed))) + } + } + + func deleteWebPageEffect(_ page: WebPageItem) -> Effect { + .run { [deleteWebPageUseCase] send in + do { + try await deleteWebPageUseCase.execute(page.url.absoluteString) + } catch { + await send(.handleWebPageDeleteFailure(page.id)) + await send(.setAlert(isPresented: true, type: .error)) + } + } + } + + func undoDeleteWebPageEffect(_ urlString: String) -> Effect { + .run { [undoDeleteWebPageUseCase, addWebPageUseCase] send in + do { + try await undoDeleteWebPageUseCase.execute(urlString) + try await addWebPageUseCase.execute(urlString) + } catch { + if let webPageURL = URL(string: urlString) { + await send(.setWebPageHidden(webPageURL, true)) + } + await send(.setAlert(isPresented: true, type: .error)) + } + } + } + + func updateTodoCategoryPreferencesEffect(_ items: [TodoCategoryItem]) -> Effect { + .run { [updatePreferencesUseCase] send in + do { + try await updatePreferencesUseCase.execute(items.map(\.preference)) + } catch { + await send(.setAlert(isPresented: true, type: .error)) + } + } + } + + func delayedModalEffect(_ type: ModalType) -> Effect { + .run { [clock] send in + try await clock.sleep(for: .seconds(0.1)) + switch type { + case .todoEditor: + await send(.setPresentation(.todoEditor, true)) + case .urlInputAlert: + await send(.setAlert(isPresented: true, type: .webPageInput)) + } + } + .cancellable(id: CancelID.delayedModal(type), cancelInFlight: true) + } + + func fetchRecentTodos(fetchTodosUseCase: FetchTodosUseCase) async throws -> TodoPage { + try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100 + ), + cursor: nil + ) + } + + static func setPresentation( + _ state: inout State, + presentation: Presentation, + isPresented: Bool + ) { + switch presentation { + case .reorderTodo: + state.reorderTodo = isPresented + case .todoEditor: + state.showTodoEditor = isPresented + if !isPresented { + state.selectedTodoCategory = nil + } + case .contentPicker: + state.showContentPicker = isPresented + case .searchView: + state.showSearchView = isPresented + } + } + + static func setAlert( + _ state: inout State, + isPresented: Bool, + type: AlertType? + ) { + switch type { + case .webPageInput: + state.alertTitle = String(localized: "home_webpage_input_title") + state.alertMessage = String(localized: "home_webpage_input_message") + state.webPageURLInput = "https://" + case .invalidURL: + state.alertTitle = String(localized: "home_invalid_url_title") + state.alertMessage = String(localized: "home_invalid_url_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 + } + + static func syncRecentTodos( + _ recentTodos: [RecentTodoItem], + preferences: [TodoCategoryItem] + ) -> [RecentTodoItem] { + recentTodos.map { recentTodo in + guard let item = preferences.first(where: { + $0.category.storageValue == recentTodo.category.storageValue + }) else { + return recentTodo + } + + var recentTodo = recentTodo + recentTodo.category = item.category + return recentTodo + } + } + + static func normalizedWebPageURL(_ input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed == "https://" || trimmed == "http://" { + return nil + } + if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { + return trimmed + } + return "https://" + trimmed + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift new file mode 100644 index 00000000..509ba766 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -0,0 +1,217 @@ +// +// HomeFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import Combine +import ComposableArchitecture +import DevLogDomain +import Foundation + +@Reducer +struct HomeFeature { + @ObservableState + struct State: Equatable { + var preferences = [TodoCategoryItem]() + var recentTodos = [RecentTodoItem]() + var webPages = [WebPageItem]() + var needsWebPageRefresh = false + var isNetworkConnected = true + var showContentPicker = false + var showTodoEditor = false + var showSearchView = false + var webPageURLInput = "https://" + var selectedTodoCategory: TodoCategory? + var reorderTodo = false + var showAlert = false + var alertTitle = "" + var alertType: AlertType? + var alertMessage = "" + var deletedWebPageURLString: String? + var loading = LoadingFeature.State() + + var isPreferencesLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.preferences.target) + } + + var isRecentTodosLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.recentTodos.target) + } + + var isWebPageLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.webPage.target) + } + + var isAppending: Bool { + loading.visibleTargets.contains(LoadingTarget.overlay.target) + } + } + + enum Action: Equatable { + case startObserving + case fetchData + case refreshRecentTodos + case networkStatusChanged(Bool) + case setPresentation(Presentation, Bool) + case setAlert(isPresented: Bool, type: AlertType? = nil) + case refreshWebPages + case setWebPageHidden(URL, Bool) + case handleWebPageDeleteFailure(URL) + case finishDeleteWebPageToast(String) + case tapTodoCategory(TodoCategory) + case orderTodoCategory([TodoCategoryItem]) + case updateWebPageURLInput(String) + case addWebPage + case deleteWebPage(WebPageItem) + case undoDeleteWebPage + case store(StoreAction) + case loading(LoadingFeature.Action) + + enum StoreAction: Equatable { + case setTodoCategory([TodoCategoryItem]) + case updateRecentTodos([RecentTodoItem]) + case updateWebPages([WebPageItem]) + } + } + + enum AlertType: Equatable { + case webPageInput + case invalidURL + case error + } + + enum ModalType: Hashable { + case todoEditor + case urlInputAlert + } + + enum Presentation: Equatable { + case reorderTodo + case todoEditor + case contentPicker + case searchView + } + + enum LoadingTarget: Hashable { + case preferences + case recentTodos + case webPage + case overlay + + var target: LoadingFeature.Target { + switch self { + case .preferences: + return LoadingFeature.Target("home.preferences") + case .recentTodos: + return LoadingFeature.Target("home.recentTodos") + case .webPage: + return LoadingFeature.Target("home.webPage") + case .overlay: + return LoadingFeature.Target("home.overlay") + } + } + } + + @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase + @Dependency(\.homeAddWebPageUseCase) var addWebPageUseCase + @Dependency(\.homeDeleteWebPageUseCase) var deleteWebPageUseCase + @Dependency(\.homeUndoDeleteWebPageUseCase) var undoDeleteWebPageUseCase + @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.homeFetchWebPagesUseCase) var fetchWebPagesUseCase + @Dependency(\.networkConnectivityUseCase) var networkConnectivityUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.continuousClock) var clock + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + Reduce { state, action in + switch action { + case .startObserving: + return observeNetworkConnectivityEffect() + case .fetchData: + return .merge( + fetchTodoCategoryPreferencesEffect(), + fetchRecentTodosEffect(), + fetchWebPagesEffect() + ) + case .refreshRecentTodos: + return fetchRecentTodosEffect() + case .networkStatusChanged(let isConnected): + state.isNetworkConnected = isConnected + case .setPresentation(let presentation, let isPresented): + Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) + case .setAlert(let isPresented, let type): + if isPresented, type == .webPageInput, state.showContentPicker { + state.showContentPicker = false + return delayedModalEffect(.urlInputAlert) + } + Self.setAlert(&state, isPresented: isPresented, type: type) + case .refreshWebPages: + return fetchWebPagesEffect() + case .setWebPageHidden(let webPageURL, let isHidden): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = isHidden + } + case .handleWebPageDeleteFailure(let webPageURL): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = false + } else { + state.needsWebPageRefresh = true + } + case .finishDeleteWebPageToast(let urlString): + state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } + if state.deletedWebPageURLString == urlString { + state.deletedWebPageURLString = nil + } + case .tapTodoCategory(let category): + state.selectedTodoCategory = category + state.showContentPicker = false + return delayedModalEffect(.todoEditor) + case .orderTodoCategory(let preferences): + state.preferences = preferences + state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + return updateTodoCategoryPreferencesEffect(preferences) + case .updateWebPageURLInput(let text): + state.webPageURLInput = text + case .addWebPage: + guard let normalizedURL = Self.normalizedWebPageURL(state.webPageURLInput) else { + Self.setAlert(&state, isPresented: true, type: .invalidURL) + return .none + } + Self.setAlert(&state, isPresented: false, type: nil) + return addWebPageEffect(normalizedURL) + case .deleteWebPage(let page): + guard let index = state.webPages.firstIndex(where: { $0.id == page.id }) else { + return .none + } + state.deletedWebPageURLString = page.url.absoluteString + state.webPages[index].isHidden = true + return deleteWebPageEffect(page) + case .undoDeleteWebPage: + guard let urlString = state.deletedWebPageURLString else { return .none } + if let index = state.webPages.firstIndex(where: { $0.url.absoluteString == urlString }) { + state.webPages[index].isHidden = false + } + state.deletedWebPageURLString = nil + return undoDeleteWebPageEffect(urlString) + case .store(.setTodoCategory(let preferences)): + state.preferences = preferences + state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + case .store(.updateRecentTodos(let todos)): + state.recentTodos = todos + case .store(.updateWebPages(let pages)): + state.webPages = pages + state.needsWebPageRefresh = false + case .loading: + break + } + + return .none + } + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 82242d23..414e9dc8 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -6,15 +6,26 @@ // import SwiftUI +import ComposableArchitecture import DevLogDomain struct HomeView: View { @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + @State var store: StoreOf let coordinator: HomeViewCoordinator let isCompactLayout: Bool + init( + coordinator: HomeViewCoordinator, + isCompactLayout: Bool + ) { + self.coordinator = coordinator + self.isCompactLayout = isCompactLayout + self._store = State(initialValue: coordinator.store) + } + var body: some View { List { todoSection @@ -25,58 +36,58 @@ struct HomeView: View { .navigationTitle(String(localized: "nav_home")) .toolbar { toolbar } .sheet(isPresented: Binding( - get: { coordinator.viewModel.state.reorderTodo }, - set: { coordinator.viewModel.send(.setPresentation(.reorderTodo, $0)) } + get: { store.reorderTodo }, + set: { store.send(.setPresentation(.reorderTodo, $0)) } )) { CategoryManageView( - preferences: coordinator.viewModel.state.preferences, + preferences: store.preferences, onDismiss: { array in - coordinator.viewModel.send(.setPresentation(.reorderTodo, false)) + store.send(.setPresentation(.reorderTodo, false)) withAnimation { - coordinator.viewModel.send(.orderTodoCategory(array)) + store.send(.orderTodoCategory(array)) } } ) } .sheet(isPresented: Binding( - get: { coordinator.viewModel.state.showContentPicker }, + get: { store.showContentPicker }, set: { _, _ in } )) { contentPicker } .fullScreenCover(isPresented: Binding( - get: { coordinator.viewModel.state.showTodoEditor }, - set: { coordinator.viewModel.send(.setPresentation(.todoEditor, $0)) } + get: { store.showTodoEditor }, + set: { store.send(.setPresentation(.todoEditor, $0)) } )) { - if let selectedCategory = coordinator.viewModel.state.selectedTodoCategory { + if let selectedCategory = store.selectedTodoCategory { TodoEditorView( store: coordinator.makeTodoEditorStore(category: selectedCategory), onCreateSuccess: { - coordinator.viewModel.send(.setPresentation(.todoEditor, false)) - coordinator.viewModel.send(.fetchData) + store.send(.setPresentation(.todoEditor, false)) + store.send(.fetchData) } ) } } .fullScreenCover(isPresented: Binding( - get: { coordinator.viewModel.state.showSearchView }, - set: { coordinator.viewModel.send(.setPresentation(.searchView, $0)) } + get: { store.showSearchView }, + set: { store.send(.setPresentation(.searchView, $0)) } )) { SearchView(store: coordinator.makeSearchStore()) } .alert( - coordinator.viewModel.state.alertTitle, + store.alertTitle, isPresented: Binding( - get: { coordinator.viewModel.state.showAlert }, - set: { coordinator.viewModel.send(.setAlert(isPresented: $0)) } + get: { store.showAlert }, + set: { store.send(.setAlert(isPresented: $0)) } ) ) { alertButtons } message: { - Text(coordinator.viewModel.state.alertMessage) + Text(store.alertMessage) } .overlay { - if coordinator.viewModel.state.isAppending { + if store.isAppending { LoadingView() } } @@ -84,36 +95,36 @@ struct HomeView: View { @ViewBuilder private var alertButtons: some View { - switch coordinator.viewModel.state.alertType { + switch store.alertType { case .webPageInput: TextField( "https://", text: Binding( - get: { coordinator.viewModel.state.webPageURLInput }, - set: { coordinator.viewModel.send(.updateWebPageURLInput($0)) } + get: { store.webPageURLInput }, + set: { store.send(.updateWebPageURLInput($0)) } ) ) .textInputAutocapitalization(.never) .keyboardType(.URL) Button(String(localized: "home_add")) { - coordinator.viewModel.send(.addWebPage) + store.send(.addWebPage) } Button(String(localized: "common_cancel"), role: .cancel) { - coordinator.viewModel.send(.setAlert(isPresented: false)) + store.send(.setAlert(isPresented: false)) } case .invalidURL, .error, .none: Button(String(localized: "common_close"), role: .cancel) { - coordinator.viewModel.send(.setAlert(isPresented: false)) + store.send(.setAlert(isPresented: false)) } } } private var todoSection: some View { Section(content: { - if coordinator.viewModel.state.isPreferencesLoading { + if store.isPreferencesLoading { LoadingView() } else { - let preferences = coordinator.viewModel.state.preferences + let preferences = store.preferences ForEach(preferences.filter { $0.isVisible }, id: \.id) { item in todoCategoryRow(item) .listRowInsets((EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))) @@ -127,7 +138,7 @@ struct HomeView: View { .bold() Spacer() Button(action: { - coordinator.viewModel.send(.setPresentation(.reorderTodo, true)) + store.send(.setPresentation(.reorderTodo, true)) }) { Image(systemName: "ellipsis") .font(.title2) @@ -140,9 +151,9 @@ struct HomeView: View { private var recentTodoSection: some View { Section { - if coordinator.viewModel.state.isRecentTodosLoading { + if store.isRecentTodosLoading { LoadingView() - } else if coordinator.viewModel.state.recentTodos.isEmpty { + } else if store.recentTodos.isEmpty { HStack { Spacer() Text(String(localized: "home_recent_empty")) @@ -150,7 +161,7 @@ struct HomeView: View { Spacer() } } else { - ForEach(coordinator.viewModel.state.recentTodos, id: \.id) { todo in + ForEach(store.recentTodos, id: \.id) { todo in recentTodoRow(todo) } } @@ -167,13 +178,13 @@ struct HomeView: View { private var webPageSection: some View { Section { - let webPages = coordinator.viewModel.state.webPages.filter { !$0.isHidden } - if coordinator.viewModel.state.isWebPageLoading { + let webPages = store.webPages.filter { !$0.isHidden } + if store.isWebPageLoading { LoadingView() .id(UUID()) // id 부여를 통해 렌더링 강제 - } else if coordinator.viewModel.state.needsWebPageRefresh { + } else if store.needsWebPageRefresh { Button { - coordinator.viewModel.send(.refreshWebPages) + store.send(.refreshWebPages) } label: { HStack { Spacer() @@ -212,18 +223,18 @@ struct HomeView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - coordinator.viewModel.send(.setPresentation(.contentPicker, true)) + store.send(.setPresentation(.contentPicker, true)) } label: { Image(systemName: "plus") } - .disabled(!coordinator.viewModel.state.isNetworkConnected) + .disabled(!store.isNetworkConnected) } if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarTrailing) } ToolbarItemGroup(placement: .topBarTrailing) { Button { - coordinator.viewModel.send(.setPresentation(.searchView, true)) + store.send(.setPresentation(.searchView, true)) } label: { Image(systemName: "magnifyingglass") } @@ -292,7 +303,8 @@ struct HomeView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - coordinator.viewModel.send(.deleteWebPage(item)) + store.send(.deleteWebPage(item)) + presentDeleteWebPageToast(item.url.absoluteString) } label: { Label(String(localized: "common_delete"), systemImage: "trash") } @@ -303,10 +315,10 @@ struct HomeView: View { NavigationStack { List { Section { - if coordinator.viewModel.state.isPreferencesLoading { + if store.isPreferencesLoading { LoadingView() } else { - let preferences = coordinator.viewModel.state.preferences.filter(\.isVisible) + let preferences = store.preferences.filter(\.isVisible) ForEach(preferences, id: \.id) { item in Button { DispatchQueue.main.async { @@ -329,7 +341,7 @@ struct HomeView: View { Section { Button { DispatchQueue.main.async { - coordinator.viewModel.send(.setAlert(isPresented: true, type: .webPageInput)) + store.send(.setAlert(isPresented: true, type: .webPageInput)) } } label: { labelImage( @@ -348,7 +360,7 @@ struct HomeView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button { - coordinator.viewModel.send(.setPresentation(.contentPicker, false)) + store.send(.setPresentation(.contentPicker, false)) } label: { Image(systemName: "xmark") .bold() @@ -381,16 +393,32 @@ struct HomeView: View { private func openTodoEditor(for todoCategory: TodoCategory) { if isiOSAppOnMac { - coordinator.viewModel.send(.setPresentation(.contentPicker, false)) + store.send(.setPresentation(.contentPicker, false)) openWindow( id: TodoEditorWindowValue.sceneId, value: TodoEditorWindowValue(todoCategory: todoCategory, source: .home) ) } else { - coordinator.viewModel.send(.tapTodoCategory(todoCategory)) + store.send(.tapTodoCategory(todoCategory)) } } + private func presentDeleteWebPageToast(_ urlString: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + font: .caption, + multilineTextAlignment: .center, + action: { + store.send(.undoDeleteWebPage) + }, + onDismiss: { + store.send(.finishDeleteWebPageToast(urlString)) + } + ) + } + } enum HomeRoute: Hashable { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 19e5ba68..329e2586 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -14,7 +14,7 @@ import DevLogDomain @MainActor @Observable final class HomeViewCoordinator { - let viewModel: HomeViewModel + let store: StoreOf let router = NavigationRouter() private let container: DIContainer @ObservationIgnored @@ -26,25 +26,30 @@ final class HomeViewCoordinator { init(container: DIContainer) { self.container = container - self.viewModel = HomeViewModel( - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), - addWebPageUseCase: container.resolve(AddWebPageUseCase.self), - deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), - undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self) - ) + self.store = Store(initialState: HomeFeature.State()) { + HomeFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.homeUpdateTodoCategoryPreferencesUseCase = container.resolve( + UpdateTodoCategoryPreferencesUseCase.self + ) + $0.homeAddWebPageUseCase = container.resolve(AddWebPageUseCase.self) + $0.homeDeleteWebPageUseCase = container.resolve(DeleteWebPageUseCase.self) + $0.homeUndoDeleteWebPageUseCase = container.resolve(UndoDeleteWebPageUseCase.self) + $0.homeFetchTodosUseCase = container.resolve(FetchTodosUseCase.self) + $0.homeFetchWebPagesUseCase = container.resolve(FetchWebPagesUseCase.self) + $0.networkConnectivityUseCase = container.resolve(ObserveNetworkConnectivityUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + } + self.store.send(.startObserving) } func fetchData() { - viewModel.send(.fetchData) + store.send(.fetchData) } func refreshRecentTodos() { - viewModel.send(.refreshRecentTodos) + store.send(.refreshRecentTodos) } func bindTodoMutationEvent() { @@ -72,7 +77,7 @@ final class HomeViewCoordinator { .sink { [weak self] submit in guard case .create(let value) = submit, value.matchesCreate(source: .home) else { return } - self?.viewModel.send(.fetchData) + self?.store.send(.fetchData) } .store(in: &cancellables) } diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift new file mode 100644 index 00000000..710f966a --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift @@ -0,0 +1,272 @@ +// +// HomeFeatureTestAssertions.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +func verifyHomeFetchData( + adapter: Adapter, + fetchTodosUseCaseSpy: FetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy +) async throws { + await adapter.fetchData() + + await waitUntil { + adapter.preferences.count == 2 + && adapter.recentTodos.count == 2 + && adapter.webPages.count == 1 + } + + #expect(adapter.preferences.map(\.id) == ["feature", "custom"]) + #expect(adapter.recentTodos.map(\.id) == ["todo-1", "todo-2"]) + #expect(adapter.webPages.map(\.url.absoluteString) == ["https://openai.com"]) + #expect(fetchTodosUseCaseSpy.queries.count == 1) + #expect(fetchTodosUseCaseSpy.queries.first?.sortTarget == .updatedAt) + #expect(fetchTodosUseCaseSpy.queries.first?.sortOrder == .latest) + #expect(fetchTodosUseCaseSpy.queries.first?.pageSize == 100) + #expect(fetchWebPagesUseCaseSpy.calledQueries == [""]) +} + +@MainActor +func verifyHomeWebPageInputAlert( + adapter: Adapter +) async throws { + await adapter.setPresentation(.contentPicker, true) + + #expect(adapter.showContentPicker) + + await adapter.setAlert(isPresented: true, type: .webPageInput) + + #expect(!adapter.showContentPicker) + #expect(!adapter.showAlert) + + await waitUntil { + adapter.showAlert + } + + #expect(adapter.showAlert) + #expect(adapter.alertType == .webPageInput) + #expect(adapter.alertTitle == String(localized: "home_webpage_input_title")) + #expect(adapter.webPageURLInput == "https://") +} + +@MainActor +func verifyHomeTapTodoCategory( + adapter: Adapter +) async throws { + await adapter.setPresentation(.contentPicker, true) + await adapter.tapTodoCategory(.system(.feature)) + + #expect(!adapter.showContentPicker) + #expect(!adapter.showTodoEditor) + + await waitUntil { + adapter.showTodoEditor + } + + #expect(adapter.showTodoEditor) +} + +@MainActor +func verifyHomeOrderTodoCategory( + adapter: Adapter, + updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy +) async throws { + await adapter.fetchData() + + let updatedCategory = TodoCategoryItem( + from: .user( + UserTodoCategory( + id: "custom", + name: "Updated", + colorHex: "#222222" + ) + ) + ) + let items = [ + updatedCategory, + TodoCategoryItem(from: .system(.feature)) + ] + + await adapter.orderTodoCategory(items) + + #expect(adapter.preferences == items) + #expect(adapter.recentTodos.last?.category == updatedCategory.category) + #expect(updatePreferencesUseCaseSpy.updates == [items.map(\.preference)]) +} + +@MainActor +func verifyHomeAddWebPage( + adapter: Adapter, + addWebPageUseCaseSpy: AddWebPageUseCaseSpy, + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy +) async throws { + await adapter.updateWebPageURLInput("openai.com") + await adapter.addWebPage() + + await waitUntil { + addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + && adapter.webPages.count == 2 + } + + #expect(addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + #expect(fetchWebPagesUseCaseSpy.calledQueries == [""]) + #expect(trackAnalyticsEventUseCaseSpy.events.count == 1) + #expect(adapter.webPages.map(\.url.absoluteString) == [ + "https://openai.com", + "https://developer.apple.com" + ]) + #expect(!adapter.showAlert) +} + +@MainActor +func verifyHomeDeleteUndoWebPage( + adapter: Adapter, + deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy, + undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy +) async throws { + await adapter.fetchData() + + let page = try #require(adapter.webPages.first) + + await adapter.deleteWebPage(page) + + #expect(adapter.webPages.first?.isHidden == true) + + await waitUntil { + deleteWebPageUseCaseSpy.calledUrlStrings == [page.url.absoluteString] + } + + await adapter.undoDeleteWebPage() + + await waitUntil { + undoDeleteWebPageUseCaseSpy.calledUrlStrings == [page.url.absoluteString] + } + + #expect(adapter.webPages.first?.isHidden == false) + + await adapter.deleteWebPage(page) + await adapter.finishDeleteWebPageToast(page.url.absoluteString) + + #expect(adapter.webPages.isEmpty) +} + +struct HomeFetchDataContext { + let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy + let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy + let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy +} + +func makeHomeFetchDataContext() -> HomeFetchDataContext { + let fetchPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() + fetchPreferencesUseCaseSpy.todoCategoryPreferences = [ + TodoCategoryPreference(category: .system(.feature), isVisible: true), + TodoCategoryPreference( + category: .user( + UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + ), + isVisible: true + ) + ] + + let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() + let createdAt = Date(timeIntervalSince1970: 0) + fetchTodosUseCaseSpy.todoPage = TodoPage( + items: [ + makeHomeTodo(id: "todo-1", category: .system(.feature), number: 1), + makeHomeTodo( + id: "todo-2", + category: .user( + UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + ), + number: 2 + ), + makeHomeTodo( + id: "todo-ignored", + number: 3, + createdAt: createdAt, + updatedAt: createdAt + ) + ], + nextCursor: nil + ) + + let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( + webPages: [makeHomeWebPage()] + ) + + return HomeFetchDataContext( + fetchPreferencesUseCaseSpy: fetchPreferencesUseCaseSpy, + fetchTodosUseCaseSpy: fetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: fetchWebPagesUseCaseSpy + ) +} + +struct HomeOrderContext { + let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy + let updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy + let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy +} + +func makeHomeOrderContext() -> HomeOrderContext { + let fetchContext = makeHomeFetchDataContext() + return HomeOrderContext( + fetchPreferencesUseCaseSpy: fetchContext.fetchPreferencesUseCaseSpy, + updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy(), + fetchTodosUseCaseSpy: fetchContext.fetchTodosUseCaseSpy + ) +} + +struct HomeAddWebPageContext { + let addWebPageUseCaseSpy: AddWebPageUseCaseSpy + let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy + let trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy +} + +func makeHomeAddWebPageContext() -> HomeAddWebPageContext { + HomeAddWebPageContext( + addWebPageUseCaseSpy: AddWebPageUseCaseSpy(), + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy( + webPages: [ + makeHomeWebPage(), + makeHomeWebPage( + title: "Apple", + urlString: "https://developer.apple.com" + ) + ] + ), + trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy() + ) +} + +struct HomeDeleteContext { + let addWebPageUseCaseSpy: AddWebPageUseCaseSpy + let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy + let deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy + let undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy +} + +func makeHomeDeleteContext() -> HomeDeleteContext { + HomeDeleteContext( + addWebPageUseCaseSpy: AddWebPageUseCaseSpy(), + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy(webPages: [makeHomeWebPage()]), + deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy(), + undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy() + ) +} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift new file mode 100644 index 00000000..03c1f862 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -0,0 +1,315 @@ +// +// HomeFeatureTestSupport.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +protocol HomeStateDriving { + var preferences: [TodoCategoryItem] { get } + var recentTodos: [RecentTodoItem] { get } + var webPages: [WebPageItem] { get } + var isNetworkConnected: Bool { get } + var showContentPicker: Bool { get } + var showTodoEditor: Bool { get } + var showAlert: Bool { get } + var alertType: HomeFeature.AlertType? { get } + var alertTitle: String { get } + var webPageURLInput: String { get } + + func startObserving() async + func fetchData() async + func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async + func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async + func tapTodoCategory(_ category: TodoCategory) async + func orderTodoCategory(_ items: [TodoCategoryItem]) async + func updateWebPageURLInput(_ input: String) async + func addWebPage() async + func deleteWebPage(_ page: WebPageItem) async + func undoDeleteWebPage() async + func finishDeleteWebPageToast(_ urlString: String) async +} + +@MainActor +struct HomeViewModelTestAdapter: HomeStateDriving { + private let viewModel: HomeViewModel + + var preferences: [TodoCategoryItem] { viewModel.state.preferences } + var recentTodos: [RecentTodoItem] { viewModel.state.recentTodos } + var webPages: [WebPageItem] { viewModel.state.webPages } + var isNetworkConnected: Bool { viewModel.state.isNetworkConnected } + var showContentPicker: Bool { viewModel.state.showContentPicker } + var showTodoEditor: Bool { viewModel.state.showTodoEditor } + var showAlert: Bool { viewModel.state.showAlert } + var alertType: HomeFeature.AlertType? { viewModel.state.alertType?.featureValue } + var alertTitle: String { viewModel.state.alertTitle } + var webPageURLInput: String { viewModel.state.webPageURLInput } + + init( + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), + updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), + addWebPageUseCase: AddWebPageUseCase = AddWebPageUseCaseSpy(), + deleteWebPageUseCase: DeleteWebPageUseCase = DeleteWebPageUseCaseSpy(), + undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase = UndoDeleteWebPageUseCaseSpy(), + fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), + fetchWebPagesUseCase: FetchWebPagesUseCase = FetchWebPagesUseCaseSpy(webPages: []), + networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy() + ) { + viewModel = HomeViewModel( + fetchPreferencesUseCase: fetchPreferencesUseCase, + updatePreferencesUseCase: updatePreferencesUseCase, + addWebPageUseCase: addWebPageUseCase, + deleteWebPageUseCase: deleteWebPageUseCase, + undoDeleteWebPageUseCase: undoDeleteWebPageUseCase, + fetchTodosUseCase: fetchTodosUseCase, + fetchWebPagesUseCase: fetchWebPagesUseCase, + networkConnectivityUseCase: networkConnectivityUseCase, + trackAnalyticsEventUseCase: trackAnalyticsEventUseCase + ) + } + + func startObserving() async { } + + func fetchData() async { + viewModel.send(.fetchData) + } + + func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { + viewModel.send(.setPresentation(presentation.viewModelValue, isPresented)) + } + + func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async { + viewModel.send(.setAlert(isPresented: isPresented, type: type?.viewModelValue)) + } + + func tapTodoCategory(_ category: TodoCategory) async { + viewModel.send(.tapTodoCategory(category)) + } + + func orderTodoCategory(_ items: [TodoCategoryItem]) async { + viewModel.send(.orderTodoCategory(items)) + } + + func updateWebPageURLInput(_ input: String) async { + viewModel.send(.updateWebPageURLInput(input)) + } + + func addWebPage() async { + viewModel.send(.addWebPage) + } + + func deleteWebPage(_ page: WebPageItem) async { + viewModel.send(.deleteWebPage(page)) + } + + func undoDeleteWebPage() async { + viewModel.send(.undoDeleteWebPage) + } + + func finishDeleteWebPageToast(_ urlString: String) async { + viewModel.send(.finishDeleteWebPageToast(urlString)) + } +} + +@MainActor +struct HomeStoreTestAdapter: HomeStateDriving { + private let store: TestStoreOf + + var preferences: [TodoCategoryItem] { store.state.preferences } + var recentTodos: [RecentTodoItem] { store.state.recentTodos } + var webPages: [WebPageItem] { store.state.webPages } + var isNetworkConnected: Bool { store.state.isNetworkConnected } + var showContentPicker: Bool { store.state.showContentPicker } + var showTodoEditor: Bool { store.state.showTodoEditor } + var showAlert: Bool { store.state.showAlert } + var alertType: HomeFeature.AlertType? { store.state.alertType } + var alertTitle: String { store.state.alertTitle } + var webPageURLInput: String { store.state.webPageURLInput } + + init( + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), + updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), + addWebPageUseCase: AddWebPageUseCase = AddWebPageUseCaseSpy(), + deleteWebPageUseCase: DeleteWebPageUseCase = DeleteWebPageUseCaseSpy(), + undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase = UndoDeleteWebPageUseCaseSpy(), + fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), + fetchWebPagesUseCase: FetchWebPagesUseCase = FetchWebPagesUseCaseSpy(webPages: []), + networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = HomeTrackAnalyticsEventUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + store = TestStore(initialState: HomeFeature.State()) { + HomeFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase + $0.homeAddWebPageUseCase = addWebPageUseCase + $0.homeDeleteWebPageUseCase = deleteWebPageUseCase + $0.homeUndoDeleteWebPageUseCase = undoDeleteWebPageUseCase + $0.homeFetchTodosUseCase = fetchTodosUseCase + $0.homeFetchWebPagesUseCase = fetchWebPagesUseCase + $0.networkConnectivityUseCase = networkConnectivityUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func startObserving() async { + await store.send(.startObserving) + await drainReceivedActions() + } + + func fetchData() async { + await store.send(.fetchData) + await drainReceivedActions() + } + + func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { + await store.send(.setPresentation(presentation, isPresented)) + } + + func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async { + await store.send(.setAlert(isPresented: isPresented, type: type)) + await drainReceivedActions() + } + + func tapTodoCategory(_ category: TodoCategory) async { + await store.send(.tapTodoCategory(category)) + await drainReceivedActions() + } + + func orderTodoCategory(_ items: [TodoCategoryItem]) async { + await store.send(.orderTodoCategory(items)) + await drainReceivedActions() + } + + func updateWebPageURLInput(_ input: String) async { + await store.send(.updateWebPageURLInput(input)) + } + + func addWebPage() async { + await store.send(.addWebPage) + await drainReceivedActions() + } + + func deleteWebPage(_ page: WebPageItem) async { + await store.send(.deleteWebPage(page)) + await drainReceivedActions() + } + + func undoDeleteWebPage() async { + await store.send(.undoDeleteWebPage) + await drainReceivedActions() + } + + func finishDeleteWebPageToast(_ urlString: String) async { + await store.send(.finishDeleteWebPageToast(urlString)) + } + + private func drainReceivedActions() async { + for _ in 0..<12 { + await store.skipReceivedActions(strict: false) + } + } +} + +final class HomeTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private(set) var events = [AnalyticsEvent]() + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +func makeHomeTodo( + id: String, + category: TodoCategory = .system(.feature), + number: Int = 1, + title: String = "Todo", + isPinned: Bool = false, + tags: [String] = [], + createdAt: Date = Date(timeIntervalSince1970: 0), + updatedAt: Date = Date(timeIntervalSince1970: 10) +) -> Todo { + Todo( + id: id, + isPinned: isPinned, + isCompleted: false, + isChecked: false, + number: number, + title: title, + content: "content", + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: tags, + category: category + ) +} + +func makeHomeWebPage( + title: String = "OpenAI", + urlString: String = "https://openai.com" +) -> WebPage { + let url = URL(string: urlString)! + return WebPage( + title: title, + url: url, + displayURL: url, + imageURL: nil + ) +} + +private extension HomeViewModel.AlertType { + var featureValue: HomeFeature.AlertType { + switch self { + case .webPageInput: + return .webPageInput + case .invalidURL: + return .invalidURL + case .error: + return .error + } + } +} + +private extension HomeFeature.AlertType { + var viewModelValue: HomeViewModel.AlertType { + switch self { + case .webPageInput: + return .webPageInput + case .invalidURL: + return .invalidURL + case .error: + return .error + } + } +} + +private extension HomeFeature.Presentation { + var viewModelValue: HomeViewModel.Presentation { + switch self { + case .reorderTodo: + return .reorderTodo + case .todoEditor: + return .todoEditor + case .contentPicker: + return .contentPicker + case .searchView: + return .searchView + } + } +} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift new file mode 100644 index 00000000..2d1d0c86 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift @@ -0,0 +1,190 @@ +// +// HomeFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct HomeFeatureTests { + @Test("현재 HomeViewModel fetchData는 preferences, recentTodos, webPages를 갱신한다") + func 현재_HomeViewModel_fetchData는_preferences_recentTodos_webPages를_갱신한다() async throws { + let context = makeHomeFetchDataContext() + let adapter = HomeViewModelTestAdapter( + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, + fetchTodosUseCase: context.fetchTodosUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy + ) + + try await verifyHomeFetchData( + adapter: adapter, + fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy + ) + } + + @Test("HomeFeature fetchData는 현재 HomeViewModel과 같은 홈 상태를 만든다") + func HomeFeature_fetchData는_현재_HomeViewModel과_같은_홈_상태를_만든다() async throws { + let context = makeHomeFetchDataContext() + let adapter = HomeStoreTestAdapter( + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, + fetchTodosUseCase: context.fetchTodosUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy + ) + + try await verifyHomeFetchData( + adapter: adapter, + fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy + ) + } + + @Test("현재 HomeViewModel setAlert(webPageInput)는 contentPicker를 닫고 alert를 지연 표시한다") + func 현재_HomeViewModel_setAlert_webPageInput는_contentPicker를_닫고_alert를_지연_표시한다() async throws { + let adapter = HomeViewModelTestAdapter() + + try await verifyHomeWebPageInputAlert(adapter: adapter) + } + + @Test("HomeFeature setAlert(webPageInput)는 현재 HomeViewModel과 같은 지연 표시를 유지한다") + func HomeFeature_setAlert_webPageInput는_현재_HomeViewModel과_같은_지연_표시를_유지한다() async throws { + let adapter = HomeStoreTestAdapter() + + try await verifyHomeWebPageInputAlert(adapter: adapter) + } + + @Test("현재 HomeViewModel tapTodoCategory는 category를 선택하고 editor를 지연 표시한다") + func 현재_HomeViewModel_tapTodoCategory는_category를_선택하고_editor를_지연_표시한다() async throws { + let adapter = HomeViewModelTestAdapter() + + try await verifyHomeTapTodoCategory(adapter: adapter) + } + + @Test("HomeFeature tapTodoCategory는 현재 HomeViewModel과 같은 editor 표시를 유지한다") + func HomeFeature_tapTodoCategory는_현재_HomeViewModel과_같은_editor_표시를_유지한다() async throws { + let adapter = HomeStoreTestAdapter() + + try await verifyHomeTapTodoCategory(adapter: adapter) + } + + @Test("현재 HomeViewModel orderTodoCategory는 recentTodos category를 동기화하고 저장한다") + func 현재_HomeViewModel_orderTodoCategory는_recentTodos_category를_동기화하고_저장한다() async throws { + let context = makeHomeOrderContext() + let adapter = HomeViewModelTestAdapter( + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, + updatePreferencesUseCase: context.updatePreferencesUseCaseSpy, + fetchTodosUseCase: context.fetchTodosUseCaseSpy + ) + + try await verifyHomeOrderTodoCategory( + adapter: adapter, + updatePreferencesUseCaseSpy: context.updatePreferencesUseCaseSpy + ) + } + + @Test("HomeFeature orderTodoCategory는 현재 HomeViewModel과 같은 recentTodos 동기화를 유지한다") + func HomeFeature_orderTodoCategory는_현재_HomeViewModel과_같은_recentTodos_동기화를_유지한다() async throws { + let context = makeHomeOrderContext() + let adapter = HomeStoreTestAdapter( + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, + updatePreferencesUseCase: context.updatePreferencesUseCaseSpy, + fetchTodosUseCase: context.fetchTodosUseCaseSpy + ) + + try await verifyHomeOrderTodoCategory( + adapter: adapter, + updatePreferencesUseCaseSpy: context.updatePreferencesUseCaseSpy + ) + } + + @Test("현재 HomeViewModel addWebPage는 URL을 정규화하고 목록을 다시 불러온다") + func 현재_HomeViewModel_addWebPage는_URL을_정규화하고_목록을_다시_불러온다() async throws { + let context = makeHomeAddWebPageContext() + let adapter = HomeViewModelTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy + ) + + try await verifyHomeAddWebPage( + adapter: adapter, + addWebPageUseCaseSpy: context.addWebPageUseCaseSpy, + fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCaseSpy: context.trackAnalyticsEventUseCaseSpy + ) + } + + @Test("HomeFeature addWebPage는 현재 HomeViewModel과 같은 URL 정규화와 목록 갱신을 유지한다") + func HomeFeature_addWebPage는_현재_HomeViewModel과_같은_URL_정규화와_목록_갱신을_유지한다() async throws { + let context = makeHomeAddWebPageContext() + let adapter = HomeStoreTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy + ) + + try await verifyHomeAddWebPage( + adapter: adapter, + addWebPageUseCaseSpy: context.addWebPageUseCaseSpy, + fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCaseSpy: context.trackAnalyticsEventUseCaseSpy + ) + } + + @Test("현재 HomeViewModel delete와 undoDeleteWebPage는 숨김 상태와 복구를 제어한다") + func 현재_HomeViewModel_delete와_undoDeleteWebPage는_숨김_상태와_복구를_제어한다() async throws { + let context = makeHomeDeleteContext() + let adapter = HomeViewModelTestAdapter( + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, + addWebPageUseCase: context.addWebPageUseCaseSpy + ) + + try await verifyHomeDeleteUndoWebPage( + adapter: adapter, + deleteWebPageUseCaseSpy: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCaseSpy: context.undoDeleteWebPageUseCaseSpy + ) + } + + @Test("HomeFeature delete와 undoDeleteWebPage는 현재 HomeViewModel과 같은 숨김 상태를 유지한다") + func HomeFeature_delete와_undoDeleteWebPage는_현재_HomeViewModel과_같은_숨김_상태를_유지한다() async throws { + let context = makeHomeDeleteContext() + let adapter = HomeStoreTestAdapter( + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, + addWebPageUseCase: context.addWebPageUseCaseSpy + ) + + try await verifyHomeDeleteUndoWebPage( + adapter: adapter, + deleteWebPageUseCaseSpy: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCaseSpy: context.undoDeleteWebPageUseCaseSpy + ) + } + + @Test("HomeFeature startObserving은 네트워크 연결 상태를 반영한다") + func HomeFeature_startObserving은_네트워크_연결_상태를_반영한다() async { + let networkUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() + let adapter = HomeStoreTestAdapter(networkConnectivityUseCase: networkUseCaseSpy) + + await adapter.startObserving() + + #expect(adapter.isNetworkConnected) + + networkUseCaseSpy.currentValueSubject.send(false) + + await waitUntil { + adapter.isNetworkConnected == false + } + + #expect(!adapter.isNetworkConnected) + } +} From 1b32fddc924b8ebb8c11ab2a260a8439ca302156 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:22:43 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor:=20tca=20=EC=9A=94=EC=86=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Home/Home/HomeView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 414e9dc8..cf0313a3 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -43,9 +43,7 @@ struct HomeView: View { preferences: store.preferences, onDismiss: { array in store.send(.setPresentation(.reorderTodo, false)) - withAnimation { - store.send(.orderTodoCategory(array)) - } + store.send(.orderTodoCategory(array), animation: .default) } ) } From 5f42ddc3df832dea2dd9e05b756c6f71e8c3148e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:24:37 +0900 Subject: [PATCH 03/21] =?UTF-8?q?refactor:=20=EC=BD=94=EB=94=94=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EA=B0=80=20Store=EC=9D=84=20=EB=93=A4?= =?UTF-8?q?=EA=B3=A0=EC=9E=88=EA=B8=B0=20=EB=95=8C=EB=AC=B8=EC=97=90=20Bin?= =?UTF-8?q?dable=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Home/Home/HomeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index cf0313a3..b66c4ca1 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -13,7 +13,7 @@ struct HomeView: View { @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) - @State var store: StoreOf + @@Bindable var store: StoreOf let coordinator: HomeViewCoordinator let isCompactLayout: Bool From 1ad6e9fb560867a392d472e6613915908be4f2fd Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:40:01 +0900 Subject: [PATCH 04/21] =?UTF-8?q?refactor:=20CasePathable=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20SheetState=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 4 +- .../Sources/Home/Home/HomeFeature.swift | 46 +++++++++++++++++-- .../Sources/Home/Home/HomeView.swift | 40 ++++++++-------- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 2af81815..7965219d 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -150,14 +150,14 @@ extension HomeFeature { ) { switch presentation { case .reorderTodo: - state.reorderTodo = isPresented + state.sheet = isPresented ? .reorderTodo : state.sheet == .reorderTodo ? nil : state.sheet case .todoEditor: state.showTodoEditor = isPresented if !isPresented { state.selectedTodoCategory = nil } case .contentPicker: - state.showContentPicker = isPresented + state.sheet = isPresented ? .contentPicker : state.sheet == .contentPicker ? nil : state.sheet case .searchView: state.showSearchView = isPresented } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 509ba766..8063059b 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -14,17 +14,16 @@ import Foundation struct HomeFeature { @ObservableState struct State: Equatable { + @Presents var sheet: SheetState? var preferences = [TodoCategoryItem]() var recentTodos = [RecentTodoItem]() var webPages = [WebPageItem]() var needsWebPageRefresh = false var isNetworkConnected = true - var showContentPicker = false var showTodoEditor = false var showSearchView = false var webPageURLInput = "https://" var selectedTodoCategory: TodoCategory? - var reorderTodo = false var showAlert = false var alertTitle = "" var alertType: AlertType? @@ -32,6 +31,14 @@ struct HomeFeature { var deletedWebPageURLString: String? var loading = LoadingFeature.State() + var showContentPicker: Bool { + sheet == .contentPicker + } + + var reorderTodo: Bool { + sheet == .reorderTodo + } + var isPreferencesLoading: Bool { loading.visibleTargets.contains(LoadingTarget.preferences.target) } @@ -50,10 +57,12 @@ struct HomeFeature { } enum Action: Equatable { + case sheet(PresentationAction) case startObserving case fetchData case refreshRecentTodos case networkStatusChanged(Bool) + case setSheet(SheetState?) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) case refreshWebPages @@ -82,6 +91,17 @@ struct HomeFeature { case error } + @ObservableState + @CasePathable + enum SheetState: Equatable { + case reorderTodo + case contentPicker + } + + enum Sheet: Equatable { + case tapCloseButton + } + enum ModalType: Hashable { case todoEditor case urlInputAlert @@ -131,6 +151,10 @@ struct HomeFeature { } Reduce { state, action in switch action { + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + case .sheet: + break case .startObserving: return observeNetworkConnectivityEffect() case .fetchData: @@ -143,11 +167,13 @@ struct HomeFeature { return fetchRecentTodosEffect() case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected + case .setSheet(let sheet): + state.sheet = sheet case .setPresentation(let presentation, let isPresented): Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let isPresented, let type): if isPresented, type == .webPageInput, state.showContentPicker { - state.showContentPicker = false + state.sheet = nil return delayedModalEffect(.urlInputAlert) } Self.setAlert(&state, isPresented: isPresented, type: type) @@ -170,7 +196,7 @@ struct HomeFeature { } case .tapTodoCategory(let category): state.selectedTodoCategory = category - state.showContentPicker = false + state.sheet = nil return delayedModalEffect(.todoEditor) case .orderTodoCategory(let preferences): state.preferences = preferences @@ -213,5 +239,17 @@ struct HomeFeature { return .none } + .ifLet(\.$sheet, action: \.sheet) { + HomeSheetFeature() + } + } +} + +private struct HomeSheetFeature: Reducer { + typealias State = HomeFeature.SheetState + typealias Action = HomeFeature.Sheet + + var body: some ReducerOf { + EmptyReducer() } } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index b66c4ca1..520152a0 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -13,7 +13,7 @@ struct HomeView: View { @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) - @@Bindable var store: StoreOf + @Bindable var store: StoreOf let coordinator: HomeViewCoordinator let isCompactLayout: Bool @@ -23,7 +23,7 @@ struct HomeView: View { ) { self.coordinator = coordinator self.isCompactLayout = isCompactLayout - self._store = State(initialValue: coordinator.store) + self.store = coordinator.store } var body: some View { @@ -35,23 +35,19 @@ struct HomeView: View { .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_home")) .toolbar { toolbar } - .sheet(isPresented: Binding( - get: { store.reorderTodo }, - set: { store.send(.setPresentation(.reorderTodo, $0)) } - )) { - CategoryManageView( - preferences: store.preferences, - onDismiss: { array in - store.send(.setPresentation(.reorderTodo, false)) - store.send(.orderTodoCategory(array), animation: .default) - } - ) - } - .sheet(isPresented: Binding( - get: { store.showContentPicker }, - set: { _, _ in } - )) { - contentPicker + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in + switch sheetStore.state { + case .reorderTodo: + CategoryManageView( + preferences: store.preferences, + onDismiss: { array in + store.send(.sheet(.dismiss)) + store.send(.orderTodoCategory(array), animation: .default) + } + ) + case .contentPicker: + contentPicker + } } .fullScreenCover(isPresented: Binding( get: { store.showTodoEditor }, @@ -136,7 +132,7 @@ struct HomeView: View { .bold() Spacer() Button(action: { - store.send(.setPresentation(.reorderTodo, true)) + store.send(.setSheet(.reorderTodo)) }) { Image(systemName: "ellipsis") .font(.title2) @@ -221,7 +217,7 @@ struct HomeView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.setPresentation(.contentPicker, true)) + store.send(.setSheet(.contentPicker)) } label: { Image(systemName: "plus") } @@ -358,7 +354,7 @@ struct HomeView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button { - store.send(.setPresentation(.contentPicker, false)) + store.send(.sheet(.presented(.tapCloseButton))) } label: { Image(systemName: "xmark") .bold() From 27a2340835090241420caf60835aacd2f1bd627a Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:43:52 +0900 Subject: [PATCH 05/21] =?UTF-8?q?refactor:=20AlertState=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 47 +++++++++++++++---- .../Sources/Home/Home/HomeFeature.swift | 13 ++--- .../Sources/Home/Home/HomeView.swift | 1 + 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 7965219d..bf61b560 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -173,19 +173,50 @@ extension HomeFeature { state.alertTitle = String(localized: "home_webpage_input_title") state.alertMessage = String(localized: "home_webpage_input_message") state.webPageURLInput = "https://" - case .invalidURL: - state.alertTitle = String(localized: "home_invalid_url_title") - state.alertMessage = String(localized: "home_invalid_url_message") - case .error: - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") + state.alert = nil + case .invalidURL, .error: + state.alert = isPresented ? alertState(for: type) : nil + state.showAlert = false + state.alertType = nil case .none: + state.alert = nil state.alertTitle = "" state.alertMessage = "" + state.showAlert = false + state.alertType = nil + } + + if type == .webPageInput { + state.showAlert = isPresented + state.alertType = type + } + } + + static func alertState(for type: AlertType?) -> AlertState { + let title: String + let message: String + + switch type { + case .invalidURL: + title = String(localized: "home_invalid_url_title") + message = String(localized: "home_invalid_url_message") + case .error, .none: + title = String(localized: "common_error_title") + message = String(localized: "common_error_message") + case .webPageInput: + title = String(localized: "home_webpage_input_title") + message = String(localized: "home_webpage_input_message") } - state.showAlert = isPresented - state.alertType = type + return AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) + } } static func syncRecentTodos( diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 8063059b..5137de92 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -14,6 +14,7 @@ import Foundation struct HomeFeature { @ObservableState struct State: Equatable { + @Presents var alert: AlertState? @Presents var sheet: SheetState? var preferences = [TodoCategoryItem]() var recentTodos = [RecentTodoItem]() @@ -24,10 +25,6 @@ struct HomeFeature { var showSearchView = false var webPageURLInput = "https://" var selectedTodoCategory: TodoCategory? - var showAlert = false - var alertTitle = "" - var alertType: AlertType? - var alertMessage = "" var deletedWebPageURLString: String? var loading = LoadingFeature.State() @@ -57,6 +54,7 @@ struct HomeFeature { } enum Action: Equatable { + case alert(PresentationAction) case sheet(PresentationAction) case startObserving case fetchData @@ -151,6 +149,8 @@ struct HomeFeature { } Reduce { state, action in switch action { + case .alert: + break case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): state.sheet = nil case .sheet: @@ -172,10 +172,6 @@ struct HomeFeature { case .setPresentation(let presentation, let isPresented): Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let isPresented, let type): - if isPresented, type == .webPageInput, state.showContentPicker { - state.sheet = nil - return delayedModalEffect(.urlInputAlert) - } Self.setAlert(&state, isPresented: isPresented, type: type) case .refreshWebPages: return fetchWebPagesEffect() @@ -239,6 +235,7 @@ struct HomeFeature { return .none } + .ifLet(\.$alert, action: \.alert) .ifLet(\.$sheet, action: \.sheet) { HomeSheetFeature() } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 520152a0..f402f884 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -69,6 +69,7 @@ struct HomeView: View { )) { SearchView(store: coordinator.makeSearchStore()) } + .alert($store.scope(state: \.alert, action: \.alert)) .alert( store.alertTitle, isPresented: Binding( From 9f0f0694a44f0477e7ff3abe5868da552e79378f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:02:55 +0900 Subject: [PATCH 06/21] =?UTF-8?q?refactor:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20url=EC=9D=80=20=EC=8B=9C=ED=8A=B8=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 33 ++------ .../Sources/Home/Home/HomeFeature.swift | 34 +++++++- .../Sources/Home/Home/HomeView.swift | 83 +++++++++---------- .../Home/HomeFeatureTestAssertions.swift | 8 +- .../Tests/Home/HomeFeatureTestSupport.swift | 37 +++++++-- 5 files changed, 111 insertions(+), 84 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index bf61b560..70ca8597 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -125,8 +125,6 @@ extension HomeFeature { switch type { case .todoEditor: await send(.setPresentation(.todoEditor, true)) - case .urlInputAlert: - await send(.setAlert(isPresented: true, type: .webPageInput)) } } .cancellable(id: CancelID.delayedModal(type), cancelInFlight: true) @@ -157,7 +155,7 @@ extension HomeFeature { state.selectedTodoCategory = nil } case .contentPicker: - state.sheet = isPresented ? .contentPicker : state.sheet == .contentPicker ? nil : state.sheet + state.sheet = isPresented ? .contentPicker : state.showContentPicker ? nil : state.sheet case .searchView: state.showSearchView = isPresented } @@ -168,31 +166,15 @@ extension HomeFeature { isPresented: Bool, type: AlertType? ) { - switch type { - case .webPageInput: - state.alertTitle = String(localized: "home_webpage_input_title") - state.alertMessage = String(localized: "home_webpage_input_message") - state.webPageURLInput = "https://" - state.alert = nil - case .invalidURL, .error: - state.alert = isPresented ? alertState(for: type) : nil - state.showAlert = false - state.alertType = nil - case .none: + guard isPresented, let type else { state.alert = nil - state.alertTitle = "" - state.alertMessage = "" - state.showAlert = false - state.alertType = nil + return } - if type == .webPageInput { - state.showAlert = isPresented - state.alertType = type - } + state.alert = alertState(for: type) } - static func alertState(for type: AlertType?) -> AlertState { + static func alertState(for type: AlertType) -> AlertState { let title: String let message: String @@ -200,12 +182,9 @@ extension HomeFeature { case .invalidURL: title = String(localized: "home_invalid_url_title") message = String(localized: "home_invalid_url_message") - case .error, .none: + case .error: title = String(localized: "common_error_title") message = String(localized: "common_error_message") - case .webPageInput: - title = String(localized: "home_webpage_input_title") - message = String(localized: "home_webpage_input_message") } return AlertState { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 5137de92..c271b2f4 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -36,6 +36,11 @@ struct HomeFeature { sheet == .reorderTodo } + var contentPickerDestination: ContentPickerState.Destination? { + guard case .contentPicker(let state) = sheet else { return nil } + return state.destination + } + var isPreferencesLoading: Bool { loading.visibleTargets.contains(LoadingTarget.preferences.target) } @@ -60,6 +65,7 @@ struct HomeFeature { case fetchData case refreshRecentTodos case networkStatusChanged(Bool) + case tapWebPageInput case setSheet(SheetState?) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) @@ -84,25 +90,35 @@ struct HomeFeature { } enum AlertType: Equatable { - case webPageInput case invalidURL case error } + @ObservableState + struct ContentPickerState: Equatable { + var destination: Destination? + + enum Destination: Equatable { + case webPageInput + } + } + @ObservableState @CasePathable enum SheetState: Equatable { case reorderTodo - case contentPicker + case contentPicker(ContentPickerState) + + static let contentPicker = Self.contentPicker(.init()) } enum Sheet: Equatable { case tapCloseButton + case setContentPickerDestination(ContentPickerState.Destination?) } enum ModalType: Hashable { case todoEditor - case urlInputAlert } enum Presentation: Equatable { @@ -153,6 +169,10 @@ struct HomeFeature { break case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): state.sheet = nil + case .sheet(.presented(.setContentPickerDestination(let destination))): + guard case .contentPicker(var sheetState) = state.sheet else { break } + sheetState.destination = destination + state.sheet = .contentPicker(sheetState) case .sheet: break case .startObserving: @@ -167,6 +187,13 @@ struct HomeFeature { return fetchRecentTodosEffect() case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected + case .tapWebPageInput: + if case .contentPicker(var sheetState) = state.sheet { + sheetState.destination = .webPageInput + state.sheet = .contentPicker(sheetState) + } else { + state.sheet = .contentPicker(.init(destination: .webPageInput)) + } case .setSheet(let sheet): state.sheet = sheet case .setPresentation(let presentation, let isPresented): @@ -205,6 +232,7 @@ struct 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 f402f884..5e44a6f3 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -70,17 +70,6 @@ struct HomeView: View { SearchView(store: coordinator.makeSearchStore()) } .alert($store.scope(state: \.alert, action: \.alert)) - .alert( - store.alertTitle, - isPresented: Binding( - get: { store.showAlert }, - set: { store.send(.setAlert(isPresented: $0)) } - ) - ) { - alertButtons - } message: { - Text(store.alertMessage) - } .overlay { if store.isAppending { LoadingView() @@ -88,32 +77,6 @@ struct HomeView: View { } } - @ViewBuilder - private var alertButtons: some View { - switch store.alertType { - case .webPageInput: - TextField( - "https://", - text: Binding( - get: { store.webPageURLInput }, - set: { store.send(.updateWebPageURLInput($0)) } - ) - ) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - Button(String(localized: "home_add")) { - store.send(.addWebPage) - } - Button(String(localized: "common_cancel"), role: .cancel) { - store.send(.setAlert(isPresented: false)) - } - case .invalidURL, .error, .none: - Button(String(localized: "common_close"), role: .cancel) { - store.send(.setAlert(isPresented: false)) - } - } - } - private var todoSection: some View { Section(content: { if store.isPreferencesLoading { @@ -335,9 +298,7 @@ struct HomeView: View { Section { Button { - DispatchQueue.main.async { - store.send(.setAlert(isPresented: true, type: .webPageInput)) - } + store.send(.tapWebPageInput) } label: { labelImage( text: "URL", @@ -350,9 +311,47 @@ struct HomeView: View { .foregroundStyle(Color(.label)) } } - .navigationTitle(String(localized: "nav_home_content")) - .navigationBarTitleDisplayMode(.inline) + .navigationDestination( + isPresented: Binding( + get: { store.contentPickerDestination == .webPageInput }, + set: { isPresented in + if !isPresented { + store.send(.sheet(.presented(.setContentPickerDestination(nil)))) + } + } + ) + ) { + Form { + Section { + TextField( + "https://", + text: Binding( + get: { store.webPageURLInput }, + set: { store.send(.updateWebPageURLInput($0)) } + ) + ) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } footer: { + Text(String(localized: "home_webpage_input_message")) + } + } + .scrollDisabled(true) + .toolbar { + ToolbarItem(placement: .principal) { + Text(String(localized: "home_webpage_input_title")) + } + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "home_add")) { + store.send(.addWebPage) + } + } + } + } .toolbar { + ToolbarItem(placement: .principal) { + Text(String(localized: "nav_home_content")) + } ToolbarItem(placement: .topBarLeading) { Button { store.send(.sheet(.presented(.tapCloseButton))) diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift index 710f966a..f0d4f876 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift @@ -42,18 +42,16 @@ func verifyHomeWebPageInputAlert( #expect(adapter.showContentPicker) - await adapter.setAlert(isPresented: true, type: .webPageInput) + await adapter.openWebPageInput() #expect(!adapter.showContentPicker) #expect(!adapter.showAlert) await waitUntil { - adapter.showAlert + adapter.showWebPageInputNavigation } - #expect(adapter.showAlert) - #expect(adapter.alertType == .webPageInput) - #expect(adapter.alertTitle == String(localized: "home_webpage_input_title")) + #expect(adapter.showWebPageInputNavigation) #expect(adapter.webPageURLInput == "https://") } diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index 03c1f862..b9a90e2c 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -19,6 +19,7 @@ protocol HomeStateDriving { var webPages: [WebPageItem] { get } var isNetworkConnected: Bool { get } var showContentPicker: Bool { get } + var showWebPageInputNavigation: Bool { get } var showTodoEditor: Bool { get } var showAlert: Bool { get } var alertType: HomeFeature.AlertType? { get } @@ -27,6 +28,7 @@ protocol HomeStateDriving { func startObserving() async func fetchData() async + func openWebPageInput() async func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async func tapTodoCategory(_ category: TodoCategory) async @@ -47,6 +49,7 @@ struct HomeViewModelTestAdapter: HomeStateDriving { var webPages: [WebPageItem] { viewModel.state.webPages } var isNetworkConnected: Bool { viewModel.state.isNetworkConnected } var showContentPicker: Bool { viewModel.state.showContentPicker } + var showWebPageInputNavigation: Bool { false } var showTodoEditor: Bool { viewModel.state.showTodoEditor } var showAlert: Bool { viewModel.state.showAlert } var alertType: HomeFeature.AlertType? { viewModel.state.alertType?.featureValue } @@ -83,6 +86,10 @@ struct HomeViewModelTestAdapter: HomeStateDriving { viewModel.send(.fetchData) } + func openWebPageInput() async { + viewModel.send(.setAlert(isPresented: true, type: .webPageInput)) + } + func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { viewModel.send(.setPresentation(presentation.viewModelValue, isPresented)) } @@ -129,10 +136,25 @@ struct HomeStoreTestAdapter: HomeStateDriving { var webPages: [WebPageItem] { store.state.webPages } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } + var showWebPageInputNavigation: Bool { store.state.contentPickerDestination == .webPageInput } var showTodoEditor: Bool { store.state.showTodoEditor } - var showAlert: Bool { store.state.showAlert } - var alertType: HomeFeature.AlertType? { store.state.alertType } - var alertTitle: String { store.state.alertTitle } + var showAlert: Bool { store.state.alert != nil } + var alertType: HomeFeature.AlertType? { + guard let title = store.state.alert?.title else { return nil } + if title == TextState(String(localized: "home_invalid_url_title")) { + return .invalidURL + } + if title == TextState(String(localized: "common_error_title")) { + return .error + } + return nil + } + var alertTitle: String { + if let title = store.state.alert?.title { + return String(state: title) + } + return "" + } var webPageURLInput: String { store.state.webPageURLInput } init( @@ -175,6 +197,11 @@ struct HomeStoreTestAdapter: HomeStateDriving { await drainReceivedActions() } + func openWebPageInput() async { + await store.send(.tapWebPageInput) + await drainReceivedActions() + } + func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { await store.send(.setPresentation(presentation, isPresented)) } @@ -276,8 +303,6 @@ func makeHomeWebPage( private extension HomeViewModel.AlertType { var featureValue: HomeFeature.AlertType { switch self { - case .webPageInput: - return .webPageInput case .invalidURL: return .invalidURL case .error: @@ -289,8 +314,6 @@ private extension HomeViewModel.AlertType { private extension HomeFeature.AlertType { var viewModelValue: HomeViewModel.AlertType { switch self { - case .webPageInput: - return .webPageInput case .invalidURL: return .invalidURL case .error: From 9a4564dc7e0f36ae6336391ecc0c65643f916662 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:33:06 +0900 Subject: [PATCH 07/21] =?UTF-8?q?refactor:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9E=85=EB=A0=A5=EC=9D=80=20=EC=96=BC=EB=9F=BF?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=8B=9C=ED=8A=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeFeature.swift | 56 ++--- .../Sources/Home/Home/HomeView.swift | 211 +++++++++--------- .../Tests/Home/HomeFeatureTestSupport.swift | 4 +- 3 files changed, 123 insertions(+), 148 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index c271b2f4..5b992af9 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -16,6 +16,7 @@ struct HomeFeature { struct State: Equatable { @Presents var alert: AlertState? @Presents var sheet: SheetState? + @Presents var webPageInput: WebPageInputState? var preferences = [TodoCategoryItem]() var recentTodos = [RecentTodoItem]() var webPages = [WebPageItem]() @@ -28,18 +29,8 @@ struct HomeFeature { var deletedWebPageURLString: String? var loading = LoadingFeature.State() - var showContentPicker: Bool { - sheet == .contentPicker - } - - var reorderTodo: Bool { - sheet == .reorderTodo - } - - var contentPickerDestination: ContentPickerState.Destination? { - guard case .contentPicker(let state) = sheet else { return nil } - return state.destination - } + var showContentPicker: Bool { sheet == .contentPicker } + var reorderTodo: Bool { sheet == .reorderTodo } var isPreferencesLoading: Bool { loading.visibleTargets.contains(LoadingTarget.preferences.target) @@ -61,6 +52,7 @@ struct HomeFeature { enum Action: Equatable { case alert(PresentationAction) case sheet(PresentationAction) + case webPageInput(PresentationAction) case startObserving case fetchData case refreshRecentTodos @@ -95,26 +87,19 @@ struct HomeFeature { } @ObservableState - struct ContentPickerState: Equatable { - var destination: Destination? - - enum Destination: Equatable { - case webPageInput - } + struct WebPageInputState: Equatable, Identifiable { + let id = UUID() } @ObservableState @CasePathable enum SheetState: Equatable { case reorderTodo - case contentPicker(ContentPickerState) - - static let contentPicker = Self.contentPicker(.init()) + case contentPicker } enum Sheet: Equatable { case tapCloseButton - case setContentPickerDestination(ContentPickerState.Destination?) } enum ModalType: Hashable { @@ -167,12 +152,12 @@ struct HomeFeature { switch action { case .alert: break + case .webPageInput(.dismiss): + state.webPageInput = nil + case .webPageInput: + break case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): state.sheet = nil - case .sheet(.presented(.setContentPickerDestination(let destination))): - guard case .contentPicker(var sheetState) = state.sheet else { break } - sheetState.destination = destination - state.sheet = .contentPicker(sheetState) case .sheet: break case .startObserving: @@ -188,12 +173,7 @@ struct HomeFeature { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected case .tapWebPageInput: - if case .contentPicker(var sheetState) = state.sheet { - sheetState.destination = .webPageInput - state.sheet = .contentPicker(sheetState) - } else { - state.sheet = .contentPicker(.init(destination: .webPageInput)) - } + state.webPageInput = .init() case .setSheet(let sheet): state.sheet = sheet case .setPresentation(let presentation, let isPresented): @@ -232,6 +212,7 @@ struct HomeFeature { Self.setAlert(&state, isPresented: true, type: .invalidURL) return .none } + state.webPageInput = nil state.sheet = nil Self.setAlert(&state, isPresented: false, type: nil) return addWebPageEffect(normalizedURL) @@ -265,14 +246,17 @@ struct HomeFeature { } .ifLet(\.$alert, action: \.alert) .ifLet(\.$sheet, action: \.sheet) { - HomeSheetFeature() + EmptyReducer() + } + .ifLet(\.$webPageInput, action: \.webPageInput) { + HomeWebPageInputFeature() } } } -private struct HomeSheetFeature: Reducer { - typealias State = HomeFeature.SheetState - typealias Action = HomeFeature.Sheet +private struct HomeWebPageInputFeature: Reducer { + typealias State = HomeFeature.WebPageInputState + typealias Action = Never var body: some ReducerOf { EmptyReducer() diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 5e44a6f3..5d33febf 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -35,20 +35,8 @@ struct HomeView: View { .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_home")) .toolbar { toolbar } - .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in - switch sheetStore.state { - case .reorderTodo: - CategoryManageView( - preferences: store.preferences, - onDismiss: { array in - store.send(.sheet(.dismiss)) - store.send(.orderTodoCategory(array), animation: .default) - } - ) - case .contentPicker: - contentPicker - } - } + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet), content: sheetContent) .fullScreenCover(isPresented: Binding( get: { store.showTodoEditor }, set: { store.send(.setPresentation(.todoEditor, $0)) } @@ -69,7 +57,6 @@ struct HomeView: View { )) { SearchView(store: coordinator.makeSearchStore()) } - .alert($store.scope(state: \.alert, action: \.alert)) .overlay { if store.isAppending { LoadingView() @@ -77,6 +64,105 @@ struct HomeView: View { } } + @ViewBuilder + private func sheetContent(_ sheetStore: Store) -> some View { + if sheetStore.state == .contentPicker { + NavigationStack { + List { + Section { + if store.isPreferencesLoading { + LoadingView() + } else { + let preferences = store.preferences.filter(\.isVisible) + ForEach(preferences, id: \.id) { item in + Button { + DispatchQueue.main.async { + openTodoEditor(for: item.category) + } + } label: { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + } + } + } header: { + Text("TODO") + .foregroundStyle(Color(.label)) + } + + Section { + Button { + store.send(.tapWebPageInput) + } label: { + labelImage( + text: "URL", + systemName: "globe", + imageColor: .blue + ) + } + } header: { + Text("Web Page") + .foregroundStyle(Color(.label)) + } + } + .navigationDestination( + item: $store.scope(state: \.webPageInput, action: \.webPageInput) + ) { _ in + Form { + Section { + TextField( + "https://", + text: Binding( + get: { store.webPageURLInput }, + set: { store.send(.updateWebPageURLInput($0)) } + ) + ) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } footer: { + Text(String(localized: "home_webpage_input_message")) + } + } + .scrollDisabled(true) + .toolbar { + ToolbarItem(placement: .principal) { + Text(String(localized: "home_webpage_input_title")) + } + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "home_add")) { + store.send(.addWebPage) + } + } + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text(String(localized: "nav_home_content")) + } + ToolbarItem(placement: .topBarLeading) { + Button { + store.send(.sheet(.presented(.tapCloseButton))) + } label: { + Image(systemName: "xmark") + .bold() + } + } + } + } + } else { + CategoryManageView( + preferences: store.preferences, + onDismiss: { array in + store.send(.sheet(.dismiss)) + store.send(.orderTodoCategory(array), animation: .default) + } + ) + } + } + private var todoSection: some View { Section(content: { if store.isPreferencesLoading { @@ -269,101 +355,6 @@ struct HomeView: View { } } - private var contentPicker: some View { - NavigationStack { - List { - Section { - if store.isPreferencesLoading { - LoadingView() - } else { - let preferences = store.preferences.filter(\.isVisible) - ForEach(preferences, id: \.id) { item in - Button { - DispatchQueue.main.async { - openTodoEditor(for: item.category) - } - } label: { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } - } - } - } header: { - Text("TODO") - .foregroundStyle(Color(.label)) - } - - Section { - Button { - store.send(.tapWebPageInput) - } label: { - labelImage( - text: "URL", - systemName: "globe", - imageColor: .blue - ) - } - } header: { - Text("Web Page") - .foregroundStyle(Color(.label)) - } - } - .navigationDestination( - isPresented: Binding( - get: { store.contentPickerDestination == .webPageInput }, - set: { isPresented in - if !isPresented { - store.send(.sheet(.presented(.setContentPickerDestination(nil)))) - } - } - ) - ) { - Form { - Section { - TextField( - "https://", - text: Binding( - get: { store.webPageURLInput }, - set: { store.send(.updateWebPageURLInput($0)) } - ) - ) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - } footer: { - Text(String(localized: "home_webpage_input_message")) - } - } - .scrollDisabled(true) - .toolbar { - ToolbarItem(placement: .principal) { - Text(String(localized: "home_webpage_input_title")) - } - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "home_add")) { - store.send(.addWebPage) - } - } - } - } - .toolbar { - ToolbarItem(placement: .principal) { - Text(String(localized: "nav_home_content")) - } - ToolbarItem(placement: .topBarLeading) { - Button { - store.send(.sheet(.presented(.tapCloseButton))) - } label: { - Image(systemName: "xmark") - .bold() - } - } - } - } - } - private func labelImage( text: String, systemName: String, diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index b9a90e2c..47863b52 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -136,7 +136,7 @@ struct HomeStoreTestAdapter: HomeStateDriving { var webPages: [WebPageItem] { store.state.webPages } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } - var showWebPageInputNavigation: Bool { store.state.contentPickerDestination == .webPageInput } + var showWebPageInputNavigation: Bool { store.state.showWebPageInput } var showTodoEditor: Bool { store.state.showTodoEditor } var showAlert: Bool { store.state.alert != nil } var alertType: HomeFeature.AlertType? { @@ -198,7 +198,7 @@ struct HomeStoreTestAdapter: HomeStateDriving { } func openWebPageInput() async { - await store.send(.tapWebPageInput) + await store.send(.sheet(.presented(.contentPicker(.tapWebPageInput)))) await drainReceivedActions() } From 56be310812821e244b91ddacf2ca94179691f6ec Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:53:01 +0900 Subject: [PATCH 08/21] =?UTF-8?q?refactor:=20FullScreenCoverState=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 4 +-- .../Sources/Home/Home/HomeFeature.swift | 28 ++++++++++++++++-- .../Sources/Home/Home/HomeView.swift | 29 +++++++++---------- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 70ca8597..0014de5c 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -150,14 +150,14 @@ extension HomeFeature { case .reorderTodo: state.sheet = isPresented ? .reorderTodo : state.sheet == .reorderTodo ? nil : state.sheet case .todoEditor: - state.showTodoEditor = isPresented + state.fullScreenCover = isPresented ? state.selectedTodoCategory.map(FullScreenCoverState.todoEditor) : nil if !isPresented { state.selectedTodoCategory = nil } case .contentPicker: state.sheet = isPresented ? .contentPicker : state.showContentPicker ? nil : state.sheet case .searchView: - state.showSearchView = isPresented + state.fullScreenCover = isPresented ? .search : nil } } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 5b992af9..e177672d 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -16,14 +16,13 @@ struct HomeFeature { struct State: Equatable { @Presents var alert: AlertState? @Presents var sheet: SheetState? + @Presents var fullScreenCover: FullScreenCoverState? @Presents var webPageInput: WebPageInputState? var preferences = [TodoCategoryItem]() var recentTodos = [RecentTodoItem]() var webPages = [WebPageItem]() var needsWebPageRefresh = false var isNetworkConnected = true - var showTodoEditor = false - var showSearchView = false var webPageURLInput = "https://" var selectedTodoCategory: TodoCategory? var deletedWebPageURLString: String? @@ -31,6 +30,8 @@ struct HomeFeature { var showContentPicker: Bool { sheet == .contentPicker } var reorderTodo: Bool { sheet == .reorderTodo } + var showTodoEditor: Bool { fullScreenCover?.destination == .todoEditor } + var showSearchView: Bool { fullScreenCover?.destination == .search } var isPreferencesLoading: Bool { loading.visibleTargets.contains(LoadingTarget.preferences.target) @@ -52,6 +53,7 @@ struct HomeFeature { enum Action: Equatable { case alert(PresentationAction) case sheet(PresentationAction) + case fullScreenCover(PresentationAction) case webPageInput(PresentationAction) case startObserving case fetchData @@ -91,6 +93,23 @@ struct HomeFeature { let id = UUID() } + @ObservableState + struct FullScreenCoverState: Equatable { + var destination: Destination + var selectedTodoCategory: TodoCategory? + + enum Destination: Equatable { + case todoEditor + case search + } + + static func todoEditor(_ category: TodoCategory) -> Self { + Self(destination: .todoEditor, selectedTodoCategory: category) + } + + static let search = Self(destination: .search) + } + @ObservableState @CasePathable enum SheetState: Equatable { @@ -152,6 +171,11 @@ struct HomeFeature { switch action { case .alert: break + case .fullScreenCover(.dismiss): + state.fullScreenCover = nil + state.selectedTodoCategory = nil + case .fullScreenCover: + break case .webPageInput(.dismiss): state.webPageInput = nil case .webPageInput: diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 5d33febf..8b2eb2c3 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -37,11 +37,19 @@ struct HomeView: View { .toolbar { toolbar } .alert($store.scope(state: \.alert, action: \.alert)) .sheet(item: $store.scope(state: \.sheet, action: \.sheet), content: sheetContent) - .fullScreenCover(isPresented: Binding( - get: { store.showTodoEditor }, - set: { store.send(.setPresentation(.todoEditor, $0)) } - )) { - if let selectedCategory = store.selectedTodoCategory { + .fullScreenCover(item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover), content: coverContent) + .overlay { + if store.isAppending { + LoadingView() + } + } + } + + @ViewBuilder + private func coverContent(_ coverStore: Store) -> some View { + switch coverStore.destination { + case .todoEditor: + if let selectedCategory = coverStore.selectedTodoCategory { TodoEditorView( store: coordinator.makeTodoEditorStore(category: selectedCategory), onCreateSuccess: { @@ -50,18 +58,9 @@ struct HomeView: View { } ) } - } - .fullScreenCover(isPresented: Binding( - get: { store.showSearchView }, - set: { store.send(.setPresentation(.searchView, $0)) } - )) { + case .search: SearchView(store: coordinator.makeSearchStore()) } - .overlay { - if store.isAppending { - LoadingView() - } - } } @ViewBuilder From 849e62410114a26e2cd08e6bc5c2505ebbd8d1c5 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:56:32 +0900 Subject: [PATCH 09/21] =?UTF-8?q?ui:=20=EC=83=81=EB=8B=A8=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=EB=B0=94=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EC=B5=9C=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeView.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 8b2eb2c3..24cb1811 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -126,10 +126,9 @@ struct HomeView: View { } } .scrollDisabled(true) + .navigationTitle(Text(String(localized: "home_webpage_input_title"))) + .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 .toolbar { - ToolbarItem(placement: .principal) { - Text(String(localized: "home_webpage_input_title")) - } ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "home_add")) { store.send(.addWebPage) @@ -137,10 +136,9 @@ struct HomeView: View { } } } + .navigationTitle(Text(String(localized: "nav_home_content"))) + .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 .toolbar { - ToolbarItem(placement: .principal) { - Text(String(localized: "nav_home_content")) - } ToolbarItem(placement: .topBarLeading) { Button { store.send(.sheet(.presented(.tapCloseButton))) From 3d36014d7fca3d1d76ed0cb30f85e7b37d5918d4 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:05:14 +0900 Subject: [PATCH 10/21] =?UTF-8?q?refactor:=20=EB=B7=B0=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=93=B0=EB=8A=94=20=EC=88=9C=EC=84=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeView.swift | 230 +++++++++--------- 1 file changed, 115 insertions(+), 115 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 24cb1811..45dd6c98 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -45,121 +45,6 @@ struct HomeView: View { } } - @ViewBuilder - private func coverContent(_ coverStore: Store) -> some View { - switch coverStore.destination { - case .todoEditor: - if let selectedCategory = coverStore.selectedTodoCategory { - TodoEditorView( - store: coordinator.makeTodoEditorStore(category: selectedCategory), - onCreateSuccess: { - store.send(.setPresentation(.todoEditor, false)) - store.send(.fetchData) - } - ) - } - case .search: - SearchView(store: coordinator.makeSearchStore()) - } - } - - @ViewBuilder - private func sheetContent(_ sheetStore: Store) -> some View { - if sheetStore.state == .contentPicker { - NavigationStack { - List { - Section { - if store.isPreferencesLoading { - LoadingView() - } else { - let preferences = store.preferences.filter(\.isVisible) - ForEach(preferences, id: \.id) { item in - Button { - DispatchQueue.main.async { - openTodoEditor(for: item.category) - } - } label: { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } - } - } - } header: { - Text("TODO") - .foregroundStyle(Color(.label)) - } - - Section { - Button { - store.send(.tapWebPageInput) - } label: { - labelImage( - text: "URL", - systemName: "globe", - imageColor: .blue - ) - } - } header: { - Text("Web Page") - .foregroundStyle(Color(.label)) - } - } - .navigationDestination( - item: $store.scope(state: \.webPageInput, action: \.webPageInput) - ) { _ in - Form { - Section { - TextField( - "https://", - text: Binding( - get: { store.webPageURLInput }, - set: { store.send(.updateWebPageURLInput($0)) } - ) - ) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - } footer: { - Text(String(localized: "home_webpage_input_message")) - } - } - .scrollDisabled(true) - .navigationTitle(Text(String(localized: "home_webpage_input_title"))) - .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "home_add")) { - store.send(.addWebPage) - } - } - } - } - .navigationTitle(Text(String(localized: "nav_home_content"))) - .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - store.send(.sheet(.presented(.tapCloseButton))) - } label: { - Image(systemName: "xmark") - .bold() - } - } - } - } - } else { - CategoryManageView( - preferences: store.preferences, - onDismiss: { array in - store.send(.sheet(.dismiss)) - store.send(.orderTodoCategory(array), animation: .default) - } - ) - } - } - private var todoSection: some View { Section(content: { if store.isPreferencesLoading { @@ -282,6 +167,121 @@ struct HomeView: View { } } + @ViewBuilder + private func sheetContent(_ sheetStore: Store) -> some View { + if sheetStore.state == .contentPicker { + NavigationStack { + List { + Section { + if store.isPreferencesLoading { + LoadingView() + } else { + let preferences = store.preferences.filter(\.isVisible) + ForEach(preferences, id: \.id) { item in + Button { + DispatchQueue.main.async { + openTodoEditor(for: item.category) + } + } label: { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + } + } + } header: { + Text("TODO") + .foregroundStyle(Color(.label)) + } + + Section { + Button { + store.send(.tapWebPageInput) + } label: { + labelImage( + text: "URL", + systemName: "globe", + imageColor: .blue + ) + } + } header: { + Text("Web Page") + .foregroundStyle(Color(.label)) + } + } + .navigationDestination( + item: $store.scope(state: \.webPageInput, action: \.webPageInput) + ) { _ in + Form { + Section { + TextField( + "https://", + text: Binding( + get: { store.webPageURLInput }, + set: { store.send(.updateWebPageURLInput($0)) } + ) + ) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } footer: { + Text(String(localized: "home_webpage_input_message")) + } + } + .scrollDisabled(true) + .navigationTitle(Text(String(localized: "home_webpage_input_title"))) + .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "home_add")) { + store.send(.addWebPage) + } + } + } + } + .navigationTitle(Text(String(localized: "nav_home_content"))) + .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + store.send(.sheet(.presented(.tapCloseButton))) + } label: { + Image(systemName: "xmark") + .bold() + } + } + } + } + } else { + CategoryManageView( + preferences: store.preferences, + onDismiss: { array in + store.send(.sheet(.dismiss)) + store.send(.orderTodoCategory(array), animation: .default) + } + ) + } + } + + @ViewBuilder + private func coverContent(_ coverStore: Store) -> some View { + switch coverStore.destination { + case .todoEditor: + if let selectedCategory = coverStore.selectedTodoCategory { + TodoEditorView( + store: coordinator.makeTodoEditorStore(category: selectedCategory), + onCreateSuccess: { + store.send(.setPresentation(.todoEditor, false)) + store.send(.fetchData) + } + ) + } + case .search: + SearchView(store: coordinator.makeSearchStore()) + } + } + @ViewBuilder private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { if isCompactLayout { From 241e6906ba99f84f60ab523db9032e393557701b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:11:13 +0900 Subject: [PATCH 11/21] =?UTF-8?q?ui:=20row=20=EA=B0=84=EC=9D=98=20?= =?UTF-8?q?=ED=8C=A8=EB=94=A9=20=EC=B5=9C=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Home/Home/HomeView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 45dd6c98..4cf48683 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -189,6 +189,7 @@ struct HomeView: View { imageColor: item.color ) } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } } } header: { @@ -206,6 +207,7 @@ struct HomeView: View { imageColor: .blue ) } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } header: { Text("Web Page") .foregroundStyle(Color(.label)) From e2ce20011682a6c84fd2d80e9736e579785b3810 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:27:39 +0900 Subject: [PATCH 12/21] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 2 - .../Sources/Home/Home/HomeFeature.swift | 4 - .../Sources/Home/Home/HomeViewModel.swift | 486 ------------------ .../Tests/Home/HomeFeatureTestSupport.swift | 2 - 4 files changed, 494 deletions(-) delete mode 100644 Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 0014de5c..5b30295d 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -147,8 +147,6 @@ extension HomeFeature { isPresented: Bool ) { switch presentation { - case .reorderTodo: - state.sheet = isPresented ? .reorderTodo : state.sheet == .reorderTodo ? nil : state.sheet case .todoEditor: state.fullScreenCover = isPresented ? state.selectedTodoCategory.map(FullScreenCoverState.todoEditor) : nil if !isPresented { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index e177672d..1a579ffb 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -5,7 +5,6 @@ // Created by opfic on 6/14/26. // -import Combine import ComposableArchitecture import DevLogDomain import Foundation @@ -29,9 +28,7 @@ struct HomeFeature { var loading = LoadingFeature.State() var showContentPicker: Bool { sheet == .contentPicker } - var reorderTodo: Bool { sheet == .reorderTodo } var showTodoEditor: Bool { fullScreenCover?.destination == .todoEditor } - var showSearchView: Bool { fullScreenCover?.destination == .search } var isPreferencesLoading: Bool { loading.visibleTargets.contains(LoadingTarget.preferences.target) @@ -126,7 +123,6 @@ struct HomeFeature { } enum Presentation: Equatable { - case reorderTodo case todoEditor case contentPicker case searchView diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift deleted file mode 100644 index c5fcdb26..00000000 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift +++ /dev/null @@ -1,486 +0,0 @@ -// -// HomeViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/22/25. -// - -import Foundation -import Combine -import DevLogCore -import DevLogDomain - -@Observable -final class HomeViewModel: StorePattern { - struct State: Equatable { - var preferences: [TodoCategoryItem] = [] - var recentTodos: [RecentTodoItem] = [] - var webPages: [WebPageItem] = [] - var needsWebPageRefresh = false - var isNetworkConnected: Bool = true - var showContentPicker: Bool = false - var showTodoEditor: Bool = false - var showSearchView: Bool = false - var webPageURLInput: String = "https://" - var selectedTodoCategory: TodoCategory? - var reorderTodo: Bool = false - var isPreferencesLoading: Bool = false - var isRecentTodosLoading: Bool = false - var isWebPageLoading: Bool = false - var isAppending: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertType: AlertType? - var alertMessage: String = "" - } - - enum Action { - case fetchData - case refreshRecentTodos - case networkStatusChanged(Bool) - case setPresentation(Presentation, Bool) - case setAlert(isPresented: Bool, type: AlertType? = nil) - case refreshWebPages - case setLoading(LoadingTarget, Bool) - case setWebPageHidden(URL, Bool) - case handleWebPageDeleteFailure(URL) - case finishDeleteWebPageToast(String) - case tapTodoCategory(TodoCategory) - case orderTodoCategory([TodoCategoryItem]) - case setTodoCategory([TodoCategoryItem]) - case updateRecentTodos([RecentTodoItem]) - case updateWebPageURLInput(String) - case addWebPage - case deleteWebPage(WebPageItem) - case undoDeleteWebPage - case updateWebPages([WebPageItem]) - } - - enum SideEffect { - case addWebPage(String) - case deleteWebPage(WebPageItem) - case undoDeleteWebPage(String) - case fetchTodoCategoryPreferences - case updateTodoCategoryPreferences([TodoCategoryItem]) - case fetchRecentTodos - case fetchWebPages - case showModalAfterDelay(ModalType) - } - - enum AlertType { - case webPageInput - case invalidURL - case error - } - - enum ModalType { - case todoEditor - case urlInputAlert - } - - enum Presentation { - case reorderTodo - case todoEditor - case contentPicker - case searchView - } - - enum LoadingTarget: Hashable { - case preferences - case recentTodos - case webPage - case overlay - } - - private(set) var state = State() - private let fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase - private let updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase - private let addWebPageUseCase: AddWebPageUseCase - private let deleteWebPageUseCase: DeleteWebPageUseCase - private let undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase - private let fetchTodosUseCase: FetchTodosUseCase - private let fetchWebPagesUseCase: FetchWebPagesUseCase - private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - private let loadingState = LoadingState() - private var deletedWebPageURLString: String? - private var cancellables = Set() - - init( - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, - updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase, - addWebPageUseCase: AddWebPageUseCase, - deleteWebPageUseCase: DeleteWebPageUseCase, - undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase, - fetchTodosUseCase: FetchTodosUseCase, - fetchWebPagesUseCase: FetchWebPagesUseCase, - networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - ) { - self.fetchPreferencesUseCase = fetchPreferencesUseCase - self.updatePreferencesUseCase = updatePreferencesUseCase - self.addWebPageUseCase = addWebPageUseCase - self.deleteWebPageUseCase = deleteWebPageUseCase - self.undoDeleteWebPageUseCase = undoDeleteWebPageUseCase - self.fetchTodosUseCase = fetchTodosUseCase - self.fetchWebPagesUseCase = fetchWebPagesUseCase - self.networkConnectivityUseCase = networkConnectivityUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - - setupNetworkObserving() - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .networkStatusChanged(let isConnected): - state.isNetworkConnected = isConnected - case .fetchData, .refreshRecentTodos, .setPresentation, .setAlert, .refreshWebPages, - .tapTodoCategory, .orderTodoCategory, .updateWebPageURLInput, - .addWebPage, .deleteWebPage, .undoDeleteWebPage, .finishDeleteWebPageToast: - effects = reduceByView(action, state: &state) - - case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory, - .updateRecentTodos, .updateWebPages: - effects = reduceByRun(action, state: &state) - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchTodoCategoryPreferences: - beginLoading(for: .preferences, mode: .immediate) - Task { - do { - defer { endLoading(for: .preferences, mode: .immediate) } - let preferences = try await fetchPreferencesUseCase.execute() - send(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .updateTodoCategoryPreferences(let items): - Task { - do { - try await updatePreferencesUseCase.execute(items.map(\.preference)) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .fetchRecentTodos: - beginLoading(for: .recentTodos, mode: .immediate) - Task { - do { - defer { endLoading(for: .recentTodos, mode: .immediate) } - let page = try await fetchRecentTodos() - let items = page.items - .filter { $0.createdAt != $0.updatedAt } - .prefix(5) - .compactMap { RecentTodoItem(from: $0) } - send(.updateRecentTodos(items)) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .addWebPage(let urlString): - beginLoading(for: .overlay, mode: .delayed) - Task { - do { - defer { endLoading(for: .overlay, mode: .delayed) } - try await addWebPageUseCase.execute(urlString) - trackAnalyticsEventUseCase.execute(.webPageCreate) - let pages = try await fetchWebPagesUseCase.execute("") - send(.updateWebPages(pages.map { WebPageItem(from: $0) })) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .deleteWebPage(let page): - Task { - do { - try await deleteWebPageUseCase.execute(page.url.absoluteString) - } catch { - send(.handleWebPageDeleteFailure(page.id)) - send(.setAlert(isPresented: true, type: .error)) - } - } - case .undoDeleteWebPage(let urlString): - Task { - do { - try await undoDeleteWebPageUseCase.execute(urlString) - try await addWebPageUseCase.execute(urlString) - } catch { - if let webPageURL = URL(string: urlString) { - send(.setWebPageHidden(webPageURL, true)) - } - send(.setAlert(isPresented: true, type: .error)) - } - } - case .fetchWebPages: - beginLoading(for: .webPage, mode: .immediate) - Task { - do { - defer { endLoading(for: .webPage, mode: .immediate) } - let pages = try await fetchWebPagesUseCase.execute("") - send(.updateWebPages(pages.map { WebPageItem(from: $0) })) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .showModalAfterDelay(let type): - Task { - try await Task.sleep(for: .seconds(0.1)) - switch type { - case .todoEditor: - send(.setPresentation(.todoEditor, true)) - case .urlInputAlert: - send(.setAlert(isPresented: true, type: .webPageInput)) - } - } - } - } -} - -// MARK: - Reduce Methods -private extension HomeViewModel { - // swiftlint:disable cyclomatic_complexity - func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .fetchData: - return [.fetchTodoCategoryPreferences, .fetchRecentTodos, .fetchWebPages] - case .refreshRecentTodos: - return [.fetchRecentTodos] - case .refreshWebPages: - return [.fetchWebPages] - case .setPresentation(let presentation, let isPresented): - setPresentation(&state, presentation: presentation, isPresented: isPresented) - case .setAlert(let presented, let type): - if presented && type == .webPageInput && state.showContentPicker { - state.showContentPicker = false - return [.showModalAfterDelay(.urlInputAlert)] - } - setAlert(&state, isPresented: presented, type: type) - case .tapTodoCategory(let category): - state.selectedTodoCategory = category - state.showContentPicker = false - return [.showModalAfterDelay(.todoEditor)] - case .orderTodoCategory(let preferences): - state.preferences = preferences - state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) - return [.updateTodoCategoryPreferences(preferences)] - case .updateWebPageURLInput(let text): - state.webPageURLInput = text - case .addWebPage: - guard let normalizedURL = normalizedWebPageURL(state.webPageURLInput) else { - setAlert(&state, isPresented: true, type: .invalidURL) - return [] - } - setAlert(&state, isPresented: false, type: nil) - return [.addWebPage(normalizedURL)] - case .deleteWebPage(let page): - if let index = state.webPages.firstIndex(where: { $0.id == page.id }) { - let urlString = page.url.absoluteString - deletedWebPageURLString = urlString - state.webPages[index].isHidden = true - presentDeleteWebPageToast(urlString) - return [.deleteWebPage(page)] - } - case .undoDeleteWebPage: - guard let deletedWebPageURLString else { return [] } - if let index = state.webPages.firstIndex(where: { - $0.url.absoluteString == deletedWebPageURLString - }) { - state.webPages[index].isHidden = false - } - self.deletedWebPageURLString = nil - return [.undoDeleteWebPage(deletedWebPageURLString)] - case .finishDeleteWebPageToast(let urlString): - state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } - if deletedWebPageURLString == urlString { - deletedWebPageURLString = nil - } - default: - break - } - return [] - } - // swiftlint:enable cyclomatic_complexity - - func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .setLoading(let loadingTarget, let isLoading): - setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) - case .setWebPageHidden(let webPageURL, let isHidden): - if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { - state.webPages[index].isHidden = isHidden - } - case .handleWebPageDeleteFailure(let webPageURL): - if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { - state.webPages[index].isHidden = false - } else { - state.needsWebPageRefresh = true - } - case .setTodoCategory(let preferences): - state.preferences = preferences - state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) - case .updateRecentTodos(let todos): - state.recentTodos = todos - case .updateWebPages(let pages): - state.webPages = pages - state.needsWebPageRefresh = false - default: - break - } - return [] - } -} - -// MARK: - Helper Methods -private extension HomeViewModel { - func setPresentation( - _ state: inout State, - presentation: Presentation, - isPresented: Bool - ) { - switch presentation { - case .reorderTodo: - state.reorderTodo = isPresented - case .todoEditor: - state.showTodoEditor = isPresented - if !isPresented { state.selectedTodoCategory = nil } - case .contentPicker: - state.showContentPicker = isPresented - case .searchView: - state.showSearchView = isPresented - } - } - - func setAlert( - _ state: inout State, - isPresented: Bool, - type: AlertType? - ) { - switch type { - case .webPageInput: - state.alertTitle = String(localized: "home_webpage_input_title") - state.alertMessage = String(localized: "home_webpage_input_message") - state.webPageURLInput = "https://" - case .invalidURL: - state.alertTitle = String(localized: "home_invalid_url_title") - state.alertMessage = String(localized: "home_invalid_url_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 - } - - func presentDeleteWebPageToast(_ urlString: String) { - ToastPresenter.present( - message: String(localized: "common_undo"), - systemImage: "arrow.uturn.left", - duration: 5, - font: .caption, - multilineTextAlignment: .center, - action: { [weak self] in - self?.send(.undoDeleteWebPage) - }, - onDismiss: { [weak self] in - self?.send(.finishDeleteWebPageToast(urlString)) - } - ) - } - - func setLoading( - _ state: inout State, - loadingTarget: LoadingTarget, - isLoading: Bool - ) { - switch loadingTarget { - case .preferences: - state.isPreferencesLoading = isLoading - case .recentTodos: - state.isRecentTodosLoading = isLoading - case .webPage: - state.isWebPageLoading = isLoading - case .overlay: - state.isAppending = isLoading - } - } - - func syncRecentTodos( - _ recentTodos: [RecentTodoItem], - preferences: [TodoCategoryItem] - ) -> [RecentTodoItem] { - recentTodos.map { recentTodo in - guard let item = preferences.first(where: { - $0.category.storageValue == recentTodo.category.storageValue - }) else { - return recentTodo - } - - var recentTodo = recentTodo - recentTodo.category = item.category - return recentTodo - } - } - - func normalizedWebPageURL(_ input: String) -> String? { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed == "https://" || trimmed == "http://" { - return nil - } - if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { - return trimmed - } - return "https://" + trimmed - } - - func fetchRecentTodos() async throws -> TodoPage { - try await fetchTodosUseCase.execute( - TodoQuery( - sortTarget: .updatedAt, - sortOrder: .latest, - pageSize: 100 - ), - cursor: nil - ) - } - - private func beginLoading( - for target: LoadingTarget, - mode: LoadingState.Mode - ) { - loadingState.begin(target: target, mode: mode) { [weak self] target, isLoading in - self?.send(.setLoading(target, isLoading)) - } - } - - private func endLoading( - for target: LoadingTarget, - mode: LoadingState.Mode - ) { - loadingState.end(target: target, mode: mode) { [weak self] target, isLoading in - self?.send(.setLoading(target, isLoading)) - } - } - - func setupNetworkObserving() { - networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) - .sink { [weak self] isConnected in - self?.send(.networkStatusChanged(isConnected)) - } - .store(in: &cancellables) - } -} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index 47863b52..4a6086a6 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -325,8 +325,6 @@ private extension HomeFeature.AlertType { private extension HomeFeature.Presentation { var viewModelValue: HomeViewModel.Presentation { switch self { - case .reorderTodo: - return .reorderTodo case .todoEditor: return .todoEditor case .contentPicker: From d27502318df13ecb15dfa856501ac6742028929d Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:36:44 +0900 Subject: [PATCH 13/21] =?UTF-8?q?refactor:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=A1=EC=85=98=EC=9D=84=20ContentPicker=20?= =?UTF-8?q?=EB=82=B4=EB=A1=9C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 2 +- .../Sources/Home/Home/HomeFeature.swift | 92 ++++++++++++++----- .../Sources/Home/Home/HomeView.swift | 9 +- .../Tests/Home/HomeFeatureTestSupport.swift | 4 +- 4 files changed, 78 insertions(+), 29 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 5b30295d..3bcce3fd 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -153,7 +153,7 @@ extension HomeFeature { state.selectedTodoCategory = nil } case .contentPicker: - state.sheet = isPresented ? .contentPicker : state.showContentPicker ? nil : state.sheet + state.sheet = isPresented ? .contentPicker(.init()) : state.showContentPicker ? nil : state.sheet case .searchView: state.fullScreenCover = isPresented ? .search : nil } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 1a579ffb..8990de09 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -16,7 +16,6 @@ struct HomeFeature { @Presents var alert: AlertState? @Presents var sheet: SheetState? @Presents var fullScreenCover: FullScreenCoverState? - @Presents var webPageInput: WebPageInputState? var preferences = [TodoCategoryItem]() var recentTodos = [RecentTodoItem]() var webPages = [WebPageItem]() @@ -27,7 +26,7 @@ struct HomeFeature { var deletedWebPageURLString: String? var loading = LoadingFeature.State() - var showContentPicker: Bool { sheet == .contentPicker } + var showContentPicker: Bool { sheet?.contentPickerState != nil } var showTodoEditor: Bool { fullScreenCover?.destination == .todoEditor } var isPreferencesLoading: Bool { @@ -51,12 +50,10 @@ struct HomeFeature { case alert(PresentationAction) case sheet(PresentationAction) case fullScreenCover(PresentationAction) - case webPageInput(PresentationAction) case startObserving case fetchData case refreshRecentTodos case networkStatusChanged(Bool) - case tapWebPageInput case setSheet(SheetState?) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) @@ -85,11 +82,46 @@ struct HomeFeature { case error } + @ObservableState + struct ContentPickerState: Equatable { + @Presents var webPageInput: WebPageInputState? + } + @ObservableState struct WebPageInputState: Equatable, Identifiable { let id = UUID() } + @ObservableState + @CasePathable + enum SheetState: Equatable { + case reorderTodo + case contentPicker(ContentPickerState) + + var contentPickerState: ContentPickerState? { + get { + guard case .contentPicker(let state) = self else { return nil } + return state + } + set { + guard let newValue else { return } + self = .contentPicker(newValue) + } + } + } + + @CasePathable + enum Sheet: Equatable { + case tapCloseButton + case contentPicker(ContentPicker) + + @CasePathable + enum ContentPicker: Equatable { + case tapWebPageInput + case webPageInput(PresentationAction) + } + } + @ObservableState struct FullScreenCoverState: Equatable { var destination: Destination @@ -107,17 +139,6 @@ struct HomeFeature { static let search = Self(destination: .search) } - @ObservableState - @CasePathable - enum SheetState: Equatable { - case reorderTodo - case contentPicker - } - - enum Sheet: Equatable { - case tapCloseButton - } - enum ModalType: Hashable { case todoEditor } @@ -172,10 +193,6 @@ struct HomeFeature { state.selectedTodoCategory = nil case .fullScreenCover: break - case .webPageInput(.dismiss): - state.webPageInput = nil - case .webPageInput: - break case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): state.sheet = nil case .sheet: @@ -192,8 +209,6 @@ struct HomeFeature { return fetchRecentTodosEffect() case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected - case .tapWebPageInput: - state.webPageInput = .init() case .setSheet(let sheet): state.sheet = sheet case .setPresentation(let presentation, let isPresented): @@ -232,7 +247,6 @@ struct HomeFeature { Self.setAlert(&state, isPresented: true, type: .invalidURL) return .none } - state.webPageInput = nil state.sheet = nil Self.setAlert(&state, isPresented: false, type: nil) return addWebPageEffect(normalizedURL) @@ -266,7 +280,39 @@ struct HomeFeature { } .ifLet(\.$alert, action: \.alert) .ifLet(\.$sheet, action: \.sheet) { - EmptyReducer() + HomeSheetFeature() + } + } +} + +private struct HomeSheetFeature: Reducer { + typealias State = HomeFeature.SheetState + typealias Action = HomeFeature.Sheet + + var body: some ReducerOf { + EmptyReducer() + .ifCaseLet(\.contentPicker, action: \.contentPicker) { + HomeContentPickerFeature() + } + } +} + +private struct HomeContentPickerFeature: Reducer { + typealias State = HomeFeature.ContentPickerState + typealias Action = HomeFeature.Sheet.ContentPicker + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .tapWebPageInput: + state.webPageInput = .init() + case .webPageInput(.dismiss): + state.webPageInput = nil + case .webPageInput: + break + } + + return .none } .ifLet(\.$webPageInput, action: \.webPageInput) { HomeWebPageInputFeature() diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 4cf48683..05e64844 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -149,7 +149,7 @@ struct HomeView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.setSheet(.contentPicker)) + store.send(.setSheet(.contentPicker(.init()))) } label: { Image(systemName: "plus") } @@ -169,7 +169,8 @@ struct HomeView: View { @ViewBuilder private func sheetContent(_ sheetStore: Store) -> some View { - if sheetStore.state == .contentPicker { + if let contentPickerStore = sheetStore.scope(state: \.contentPickerState, action: \.contentPicker) { + @Bindable var contentPickerStore = contentPickerStore NavigationStack { List { Section { @@ -199,7 +200,7 @@ struct HomeView: View { Section { Button { - store.send(.tapWebPageInput) + contentPickerStore.send(.tapWebPageInput) } label: { labelImage( text: "URL", @@ -214,7 +215,7 @@ struct HomeView: View { } } .navigationDestination( - item: $store.scope(state: \.webPageInput, action: \.webPageInput) + item: $contentPickerStore.scope(state: \.webPageInput, action: \.webPageInput) ) { _ in Form { Section { diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index 4a6086a6..11fed514 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -136,7 +136,9 @@ struct HomeStoreTestAdapter: HomeStateDriving { var webPages: [WebPageItem] { store.state.webPages } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } - var showWebPageInputNavigation: Bool { store.state.showWebPageInput } + var showWebPageInputNavigation: Bool { + store.state.sheet?.contentPickerState?.webPageInput != nil + } var showTodoEditor: Bool { store.state.showTodoEditor } var showAlert: Bool { store.state.alert != nil } var alertType: HomeFeature.AlertType? { From e981b0283d49fdc39a945bccd95cbef18b17de6e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:39:14 +0900 Subject: [PATCH 14/21] =?UTF-8?q?refactor:=20ModalType=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeFeature+Effects.swift | 11 ++++------- .../Sources/Home/Home/HomeFeature.swift | 6 +----- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 3bcce3fd..5cadcc64 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -13,7 +13,7 @@ import Foundation extension HomeFeature { private enum CancelID: Hashable { - case delayedModal(ModalType) + case delayedTodoEditor case networkConnectivity } @@ -119,15 +119,12 @@ extension HomeFeature { } } - func delayedModalEffect(_ type: ModalType) -> Effect { + func delayedTodoEditorEffect() -> Effect { .run { [clock] send in try await clock.sleep(for: .seconds(0.1)) - switch type { - case .todoEditor: - await send(.setPresentation(.todoEditor, true)) - } + await send(.setPresentation(.todoEditor, true)) } - .cancellable(id: CancelID.delayedModal(type), cancelInFlight: true) + .cancellable(id: CancelID.delayedTodoEditor, cancelInFlight: true) } func fetchRecentTodos(fetchTodosUseCase: FetchTodosUseCase) async throws -> TodoPage { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 8990de09..fdbff7c9 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -139,10 +139,6 @@ struct HomeFeature { static let search = Self(destination: .search) } - enum ModalType: Hashable { - case todoEditor - } - enum Presentation: Equatable { case todoEditor case contentPicker @@ -235,7 +231,7 @@ struct HomeFeature { case .tapTodoCategory(let category): state.selectedTodoCategory = category state.sheet = nil - return delayedModalEffect(.todoEditor) + return delayedTodoEditorEffect() case .orderTodoCategory(let preferences): state.preferences = preferences state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) From 77b2a577f43de8f75752173c7cf90d9041085b10 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:25:11 +0900 Subject: [PATCH 15/21] =?UTF-8?q?test:=20HomeFeature=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/HomeFeatureTestAssertions.swift | 60 ++----- .../Tests/Home/HomeFeatureTestSupport.swift | 168 ++---------------- .../Tests/Home/HomeFeatureTests.swift | 143 +++++---------- .../Tests/WebPage/DeleteWebPageTests.swift | 129 -------------- 4 files changed, 74 insertions(+), 426 deletions(-) delete mode 100644 Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift index f0d4f876..96d21f2b 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift @@ -11,8 +11,8 @@ import DevLogDomain @testable import DevLogPresentation @MainActor -func verifyHomeFetchData( - adapter: Adapter, +func verifyHomeFetchData( + adapter: HomeStoreTestAdapter, fetchTodosUseCaseSpy: FetchTodosUseCaseSpy, fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy ) async throws { @@ -35,8 +35,8 @@ func verifyHomeFetchData( } @MainActor -func verifyHomeWebPageInputAlert( - adapter: Adapter +func verifyHomeWebPageInputAlert( + adapter: HomeStoreTestAdapter ) async throws { await adapter.setPresentation(.contentPicker, true) @@ -44,7 +44,7 @@ func verifyHomeWebPageInputAlert( await adapter.openWebPageInput() - #expect(!adapter.showContentPicker) + #expect(adapter.showContentPicker) #expect(!adapter.showAlert) await waitUntil { @@ -56,25 +56,19 @@ func verifyHomeWebPageInputAlert( } @MainActor -func verifyHomeTapTodoCategory( - adapter: Adapter +func verifyHomeTapTodoCategory( + adapter: HomeStoreTestAdapter ) async throws { await adapter.setPresentation(.contentPicker, true) await adapter.tapTodoCategory(.system(.feature)) #expect(!adapter.showContentPicker) - #expect(!adapter.showTodoEditor) - - await waitUntil { - adapter.showTodoEditor - } - #expect(adapter.showTodoEditor) } @MainActor -func verifyHomeOrderTodoCategory( - adapter: Adapter, +func verifyHomeOrderTodoCategory( + adapter: HomeStoreTestAdapter, updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy ) async throws { await adapter.fetchData() @@ -101,8 +95,8 @@ func verifyHomeOrderTodoCategory( } @MainActor -func verifyHomeAddWebPage( - adapter: Adapter, +func verifyHomeAddWebPage( + adapter: HomeStoreTestAdapter, addWebPageUseCaseSpy: AddWebPageUseCaseSpy, fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy, trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy @@ -125,38 +119,6 @@ func verifyHomeAddWebPage( #expect(!adapter.showAlert) } -@MainActor -func verifyHomeDeleteUndoWebPage( - adapter: Adapter, - deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy, - undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy -) async throws { - await adapter.fetchData() - - let page = try #require(adapter.webPages.first) - - await adapter.deleteWebPage(page) - - #expect(adapter.webPages.first?.isHidden == true) - - await waitUntil { - deleteWebPageUseCaseSpy.calledUrlStrings == [page.url.absoluteString] - } - - await adapter.undoDeleteWebPage() - - await waitUntil { - undoDeleteWebPageUseCaseSpy.calledUrlStrings == [page.url.absoluteString] - } - - #expect(adapter.webPages.first?.isHidden == false) - - await adapter.deleteWebPage(page) - await adapter.finishDeleteWebPageToast(page.url.absoluteString) - - #expect(adapter.webPages.isEmpty) -} - struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index 11fed514..67048aff 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -11,125 +11,12 @@ import ComposableArchitecture import DevLogCore import DevLogDomain @testable import DevLogPresentation +import Foundation @MainActor -protocol HomeStateDriving { - var preferences: [TodoCategoryItem] { get } - var recentTodos: [RecentTodoItem] { get } - var webPages: [WebPageItem] { get } - var isNetworkConnected: Bool { get } - var showContentPicker: Bool { get } - var showWebPageInputNavigation: Bool { get } - var showTodoEditor: Bool { get } - var showAlert: Bool { get } - var alertType: HomeFeature.AlertType? { get } - var alertTitle: String { get } - var webPageURLInput: String { get } - - func startObserving() async - func fetchData() async - func openWebPageInput() async - func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async - func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async - func tapTodoCategory(_ category: TodoCategory) async - func orderTodoCategory(_ items: [TodoCategoryItem]) async - func updateWebPageURLInput(_ input: String) async - func addWebPage() async - func deleteWebPage(_ page: WebPageItem) async - func undoDeleteWebPage() async - func finishDeleteWebPageToast(_ urlString: String) async -} - -@MainActor -struct HomeViewModelTestAdapter: HomeStateDriving { - private let viewModel: HomeViewModel - - var preferences: [TodoCategoryItem] { viewModel.state.preferences } - var recentTodos: [RecentTodoItem] { viewModel.state.recentTodos } - var webPages: [WebPageItem] { viewModel.state.webPages } - var isNetworkConnected: Bool { viewModel.state.isNetworkConnected } - var showContentPicker: Bool { viewModel.state.showContentPicker } - var showWebPageInputNavigation: Bool { false } - var showTodoEditor: Bool { viewModel.state.showTodoEditor } - var showAlert: Bool { viewModel.state.showAlert } - var alertType: HomeFeature.AlertType? { viewModel.state.alertType?.featureValue } - var alertTitle: String { viewModel.state.alertTitle } - var webPageURLInput: String { viewModel.state.webPageURLInput } - - init( - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), - updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), - addWebPageUseCase: AddWebPageUseCase = AddWebPageUseCaseSpy(), - deleteWebPageUseCase: DeleteWebPageUseCase = DeleteWebPageUseCaseSpy(), - undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase = UndoDeleteWebPageUseCaseSpy(), - fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), - fetchWebPagesUseCase: FetchWebPagesUseCase = FetchWebPagesUseCaseSpy(webPages: []), - networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy() - ) { - viewModel = HomeViewModel( - fetchPreferencesUseCase: fetchPreferencesUseCase, - updatePreferencesUseCase: updatePreferencesUseCase, - addWebPageUseCase: addWebPageUseCase, - deleteWebPageUseCase: deleteWebPageUseCase, - undoDeleteWebPageUseCase: undoDeleteWebPageUseCase, - fetchTodosUseCase: fetchTodosUseCase, - fetchWebPagesUseCase: fetchWebPagesUseCase, - networkConnectivityUseCase: networkConnectivityUseCase, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCase - ) - } - - func startObserving() async { } - - func fetchData() async { - viewModel.send(.fetchData) - } - - func openWebPageInput() async { - viewModel.send(.setAlert(isPresented: true, type: .webPageInput)) - } - - func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { - viewModel.send(.setPresentation(presentation.viewModelValue, isPresented)) - } - - func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async { - viewModel.send(.setAlert(isPresented: isPresented, type: type?.viewModelValue)) - } - - func tapTodoCategory(_ category: TodoCategory) async { - viewModel.send(.tapTodoCategory(category)) - } - - func orderTodoCategory(_ items: [TodoCategoryItem]) async { - viewModel.send(.orderTodoCategory(items)) - } - - func updateWebPageURLInput(_ input: String) async { - viewModel.send(.updateWebPageURLInput(input)) - } - - func addWebPage() async { - viewModel.send(.addWebPage) - } - - func deleteWebPage(_ page: WebPageItem) async { - viewModel.send(.deleteWebPage(page)) - } - - func undoDeleteWebPage() async { - viewModel.send(.undoDeleteWebPage) - } - - func finishDeleteWebPageToast(_ urlString: String) async { - viewModel.send(.finishDeleteWebPageToast(urlString)) - } -} - -@MainActor -struct HomeStoreTestAdapter: HomeStateDriving { +struct HomeStoreTestAdapter { private let store: TestStoreOf + private let clock: TestClock var preferences: [TodoCategoryItem] { store.state.preferences } var recentTodos: [RecentTodoItem] { store.state.recentTodos } @@ -171,6 +58,8 @@ struct HomeStoreTestAdapter: HomeStateDriving { trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = HomeTrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { + let clock = TestClock() + self.clock = clock store = TestStore(initialState: HomeFeature.State()) { HomeFeature() } withDependencies: { @@ -183,7 +72,7 @@ struct HomeStoreTestAdapter: HomeStateDriving { $0.homeFetchWebPagesUseCase = fetchWebPagesUseCase $0.networkConnectivityUseCase = networkConnectivityUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - $0.continuousClock = ContinuousClock() + $0.continuousClock = clock configureDependencies?(&$0) } store.exhaustivity = .off(showSkippedAssertions: false) @@ -215,7 +104,8 @@ struct HomeStoreTestAdapter: HomeStateDriving { func tapTodoCategory(_ category: TodoCategory) async { await store.send(.tapTodoCategory(category)) - await drainReceivedActions() + await clock.advance(by: .seconds(1)) + await settle() } func orderTodoCategory(_ items: [TodoCategoryItem]) async { @@ -246,11 +136,16 @@ struct HomeStoreTestAdapter: HomeStateDriving { await store.send(.finishDeleteWebPageToast(urlString)) } - private func drainReceivedActions() async { + func drainReceivedActions() async { for _ in 0..<12 { await store.skipReceivedActions(strict: false) } } + + func settle() async { + await Task.yield() + await drainReceivedActions() + } } final class HomeTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { @@ -301,38 +196,3 @@ func makeHomeWebPage( imageURL: nil ) } - -private extension HomeViewModel.AlertType { - var featureValue: HomeFeature.AlertType { - switch self { - case .invalidURL: - return .invalidURL - case .error: - return .error - } - } -} - -private extension HomeFeature.AlertType { - var viewModelValue: HomeViewModel.AlertType { - switch self { - case .invalidURL: - return .invalidURL - case .error: - return .error - } - } -} - -private extension HomeFeature.Presentation { - var viewModelValue: HomeViewModel.Presentation { - switch self { - case .todoEditor: - return .todoEditor - case .contentPicker: - return .contentPicker - case .searchView: - return .searchView - } - } -} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift index 2d1d0c86..383269f0 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift @@ -12,24 +12,8 @@ import DevLogDomain @MainActor struct HomeFeatureTests { - @Test("현재 HomeViewModel fetchData는 preferences, recentTodos, webPages를 갱신한다") - func 현재_HomeViewModel_fetchData는_preferences_recentTodos_webPages를_갱신한다() async throws { - let context = makeHomeFetchDataContext() - let adapter = HomeViewModelTestAdapter( - fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy, - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy - ) - - try await verifyHomeFetchData( - adapter: adapter, - fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy, - fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy - ) - } - - @Test("HomeFeature fetchData는 현재 HomeViewModel과 같은 홈 상태를 만든다") - func HomeFeature_fetchData는_현재_HomeViewModel과_같은_홈_상태를_만든다() async throws { + @Test("HomeFeature fetchData는 홈 상태를 갱신한다") + func HomeFeature_fetchData는_홈_상태를_갱신한다() async throws { let context = makeHomeFetchDataContext() let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, @@ -44,51 +28,22 @@ struct HomeFeatureTests { ) } - @Test("현재 HomeViewModel setAlert(webPageInput)는 contentPicker를 닫고 alert를 지연 표시한다") - func 현재_HomeViewModel_setAlert_webPageInput는_contentPicker를_닫고_alert를_지연_표시한다() async throws { - let adapter = HomeViewModelTestAdapter() - - try await verifyHomeWebPageInputAlert(adapter: adapter) - } - - @Test("HomeFeature setAlert(webPageInput)는 현재 HomeViewModel과 같은 지연 표시를 유지한다") - func HomeFeature_setAlert_webPageInput는_현재_HomeViewModel과_같은_지연_표시를_유지한다() async throws { + @Test("HomeFeature webPageInput은 contentPicker 내부 내비게이션을 표시한다") + func HomeFeature_webPageInput은_contentPicker_내부_내비게이션을_표시한다() async throws { let adapter = HomeStoreTestAdapter() try await verifyHomeWebPageInputAlert(adapter: adapter) } - @Test("현재 HomeViewModel tapTodoCategory는 category를 선택하고 editor를 지연 표시한다") - func 현재_HomeViewModel_tapTodoCategory는_category를_선택하고_editor를_지연_표시한다() async throws { - let adapter = HomeViewModelTestAdapter() - - try await verifyHomeTapTodoCategory(adapter: adapter) - } - - @Test("HomeFeature tapTodoCategory는 현재 HomeViewModel과 같은 editor 표시를 유지한다") - func HomeFeature_tapTodoCategory는_현재_HomeViewModel과_같은_editor_표시를_유지한다() async throws { + @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") + func HomeFeature_tapTodoCategory는_editor를_지연_표시한다() async throws { let adapter = HomeStoreTestAdapter() try await verifyHomeTapTodoCategory(adapter: adapter) } - @Test("현재 HomeViewModel orderTodoCategory는 recentTodos category를 동기화하고 저장한다") - func 현재_HomeViewModel_orderTodoCategory는_recentTodos_category를_동기화하고_저장한다() async throws { - let context = makeHomeOrderContext() - let adapter = HomeViewModelTestAdapter( - fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - updatePreferencesUseCase: context.updatePreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy - ) - - try await verifyHomeOrderTodoCategory( - adapter: adapter, - updatePreferencesUseCaseSpy: context.updatePreferencesUseCaseSpy - ) - } - - @Test("HomeFeature orderTodoCategory는 현재 HomeViewModel과 같은 recentTodos 동기화를 유지한다") - func HomeFeature_orderTodoCategory는_현재_HomeViewModel과_같은_recentTodos_동기화를_유지한다() async throws { + @Test("HomeFeature orderTodoCategory는 recentTodos category를 동기화하고 저장한다") + func HomeFeature_orderTodoCategory는_recentTodos_category를_동기화하고_저장한다() async throws { let context = makeHomeOrderContext() let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, @@ -102,10 +57,10 @@ struct HomeFeatureTests { ) } - @Test("현재 HomeViewModel addWebPage는 URL을 정규화하고 목록을 다시 불러온다") - func 현재_HomeViewModel_addWebPage는_URL을_정규화하고_목록을_다시_불러온다() async throws { + @Test("HomeFeature addWebPage는 URL을 정규화하고 목록을 다시 불러온다") + func HomeFeature_addWebPage는_URL을_정규화하고_목록을_다시_불러온다() async throws { let context = makeHomeAddWebPageContext() - let adapter = HomeViewModelTestAdapter( + let adapter = HomeStoreTestAdapter( addWebPageUseCase: context.addWebPageUseCaseSpy, fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy @@ -119,55 +74,58 @@ struct HomeFeatureTests { ) } - @Test("HomeFeature addWebPage는 현재 HomeViewModel과 같은 URL 정규화와 목록 갱신을 유지한다") - func HomeFeature_addWebPage는_현재_HomeViewModel과_같은_URL_정규화와_목록_갱신을_유지한다() async throws { - let context = makeHomeAddWebPageContext() + @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 삭제 유스케이스가 호출된다") + func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_삭제_유스케이스가_호출된다() async throws { + let context = makeHomeDeleteContext() let adapter = HomeStoreTestAdapter( addWebPageUseCase: context.addWebPageUseCaseSpy, + deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, - trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy ) - try await verifyHomeAddWebPage( - adapter: adapter, - addWebPageUseCaseSpy: context.addWebPageUseCaseSpy, - fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy, - trackAnalyticsEventUseCaseSpy: context.trackAnalyticsEventUseCaseSpy - ) - } + await adapter.fetchData() - @Test("현재 HomeViewModel delete와 undoDeleteWebPage는 숨김 상태와 복구를 제어한다") - func 현재_HomeViewModel_delete와_undoDeleteWebPage는_숨김_상태와_복구를_제어한다() async throws { - let context = makeHomeDeleteContext() - let adapter = HomeViewModelTestAdapter( - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, - deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, - addWebPageUseCase: context.addWebPageUseCaseSpy - ) + let webPageItem = try #require(adapter.webPages.first) - try await verifyHomeDeleteUndoWebPage( - adapter: adapter, - deleteWebPageUseCaseSpy: context.deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCaseSpy: context.undoDeleteWebPageUseCaseSpy - ) + await adapter.deleteWebPage(webPageItem) + + #expect(adapter.webPages.filter { !$0.isHidden }.isEmpty) + + await waitUntil { + context.deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + } + + #expect(context.deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) } - @Test("HomeFeature delete와 undoDeleteWebPage는 현재 HomeViewModel과 같은 숨김 상태를 유지한다") - func HomeFeature_delete와_undoDeleteWebPage는_현재_HomeViewModel과_같은_숨김_상태를_유지한다() async throws { + @Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") + func 웹페이지_삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { let context = makeHomeDeleteContext() let adapter = HomeStoreTestAdapter( - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + addWebPageUseCase: context.addWebPageUseCaseSpy, deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, - addWebPageUseCase: context.addWebPageUseCaseSpy + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, ) - try await verifyHomeDeleteUndoWebPage( - adapter: adapter, - deleteWebPageUseCaseSpy: context.deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCaseSpy: context.undoDeleteWebPageUseCaseSpy - ) + await adapter.fetchData() + + let webPageItem = try #require(adapter.webPages.first) + + await adapter.deleteWebPage(webPageItem) + await adapter.undoDeleteWebPage() + + await waitUntil { + context.undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + } + + let restoredWebPageItem = try #require(adapter.webPages.first { + $0.url.absoluteString == "https://openai.com" + }) + + #expect(context.undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + #expect(!restoredWebPageItem.isHidden) } @Test("HomeFeature startObserving은 네트워크 연결 상태를 반영한다") @@ -180,10 +138,7 @@ struct HomeFeatureTests { #expect(adapter.isNetworkConnected) networkUseCaseSpy.currentValueSubject.send(false) - - await waitUntil { - adapter.isNetworkConnected == false - } + await adapter.settle() #expect(!adapter.isNetworkConnected) } diff --git a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift b/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift deleted file mode 100644 index a04cee9f..00000000 --- a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// DeleteWebPageTests.swift -// DevLogPresentationTests -// -// Created by opfic on 4/6/26. -// - -import Testing -import Foundation -import DevLogDomain -@testable import DevLogPresentation - -@MainActor -struct DeleteWebPageTests { - @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") - func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { - ToastPresenter.reset() - - let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() - let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() - let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() - let deleteWebPageUseCaseSpy = DeleteWebPageUseCaseSpy() - let undoDeleteWebPageUseCaseSpy = UndoDeleteWebPageUseCaseSpy() - let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() - let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( - webPages: [ - WebPage( - title: "OpenAI", - url: URL(string: "https://openai.com")!, - displayURL: URL(string: "https://openai.com")!, - imageURL: nil - ) - ] - ) - let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() - let trackAnalyticsEventUseCaseSpy = TrackAnalyticsEventUseCaseSpy() - - let homeViewModel = HomeViewModel( - fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, - updatePreferencesUseCase: updateTodoCategoryPreferencesUseCaseSpy, - addWebPageUseCase: addWebPageUseCaseSpy, - deleteWebPageUseCase: deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCase: undoDeleteWebPageUseCaseSpy, - fetchTodosUseCase: fetchTodosUseCaseSpy, - fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, - networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCaseSpy - ) - - homeViewModel.send(.fetchData) - await waitUntil { - !homeViewModel.state.webPages.isEmpty - } - - let webPageItem = try #require(homeViewModel.state.webPages.first) - - homeViewModel.send(.deleteWebPage(webPageItem)) - - #expect(homeViewModel.state.webPages.filter { !$0.isHidden }.isEmpty) - #expect(ToastPresenter.item?.message == String(localized: "common_undo")) - - await waitUntil { - deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] - } - - #expect(deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) - } - - @Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") - func 웹페이지_삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { - ToastPresenter.reset() - - let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() - let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() - let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() - let deleteWebPageUseCaseSpy = DeleteWebPageUseCaseSpy() - let undoDeleteWebPageUseCaseSpy = UndoDeleteWebPageUseCaseSpy() - let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() - let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( - webPages: [ - WebPage( - title: "OpenAI", - url: URL(string: "https://openai.com")!, - displayURL: URL(string: "https://openai.com")!, - imageURL: nil - ) - ] - ) - let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() - let trackAnalyticsEventUseCaseSpy = TrackAnalyticsEventUseCaseSpy() - - let homeViewModel = HomeViewModel( - fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, - updatePreferencesUseCase: updateTodoCategoryPreferencesUseCaseSpy, - addWebPageUseCase: addWebPageUseCaseSpy, - deleteWebPageUseCase: deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCase: undoDeleteWebPageUseCaseSpy, - fetchTodosUseCase: fetchTodosUseCaseSpy, - fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, - networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCaseSpy - ) - - homeViewModel.send(.fetchData) - await waitUntil { - !homeViewModel.state.webPages.isEmpty - } - - let webPageItem = try #require(homeViewModel.state.webPages.first) - - homeViewModel.send(.deleteWebPage(webPageItem)) - homeViewModel.send(.undoDeleteWebPage) - - await waitUntil { - undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] - } - - let restoredWebPageItem = try #require(homeViewModel.state.webPages.first { - $0.url.absoluteString == "https://openai.com" - }) - - #expect(undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) - #expect(!restoredWebPageItem.isHidden) - } -} - -private struct TrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { - func execute(_ event: AnalyticsEvent) { } -} From a7ebb53c31a9e4c01e00571a920537c97a3a0500 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:38:24 +0900 Subject: [PATCH 16/21] =?UTF-8?q?refactor:=20=ED=95=98=EB=82=98=EC=9D=98?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=EB=A7=8C=20send=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Home/Home/HomeFeature.swift | 1 + Application/DevLogPresentation/Sources/Home/Home/HomeView.swift | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index fdbff7c9..b2e2355b 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -235,6 +235,7 @@ struct HomeFeature { case .orderTodoCategory(let preferences): state.preferences = preferences state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + state.sheet = nil return updateTodoCategoryPreferencesEffect(preferences) case .updateWebPageURLInput(let text): state.webPageURLInput = text diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 05e64844..31d5def9 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -260,7 +260,6 @@ struct HomeView: View { CategoryManageView( preferences: store.preferences, onDismiss: { array in - store.send(.sheet(.dismiss)) store.send(.orderTodoCategory(array), animation: .default) } ) From 4607a37291736a523338086cbf5439cdeb72b5f5 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:44:09 +0900 Subject: [PATCH 17/21] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20DispatchQueue.main.async=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeFeature+Effects.swift | 1 + .../DevLogPresentation/Sources/Home/Home/HomeView.swift | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 5cadcc64..30ab98a6 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -121,6 +121,7 @@ extension HomeFeature { func delayedTodoEditorEffect() -> Effect { .run { [clock] send in + // iOS 17에서 시트 dismiss 직후 fullScreenCover를 바로 올리지 않도록 하기 위해서 0.1초 딜레이 try await clock.sleep(for: .seconds(0.1)) await send(.setPresentation(.todoEditor, true)) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 31d5def9..a9342842 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -180,9 +180,7 @@ struct HomeView: View { let preferences = store.preferences.filter(\.isVisible) ForEach(preferences, id: \.id) { item in Button { - DispatchQueue.main.async { - openTodoEditor(for: item.category) - } + openTodoEditor(for: item.category) } label: { labelImage( text: item.localizedName, From 53f30940d6a69d99a80101b7736f41114feb546b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:55:14 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix:=20Today=20fetchData=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=91=EB=A0=AC=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Today/TodayFeatureTestAssertions.swift | 24 ++++++++++---- .../Tests/Today/TodayFeatureTestSpies.swift | 32 ++++++++++++++++--- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift index b84534a3..09290eb1 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift @@ -35,13 +35,23 @@ func verifyTodayFetchData( adapter.todos.count == 5 } - #expect(fetchUseCaseSpy.queries.map(\.dueDateFilter) == [.withDueDate, .withoutDueDate]) - #expect(fetchUseCaseSpy.queries.map(\.completionFilter) == [.incomplete, .incomplete]) - #expect(fetchUseCaseSpy.queries.map(\.sortTarget) == [.dueDate, .updatedAt]) - #expect(fetchUseCaseSpy.queries.map(\.sortOrder) == [.oldest, .latest]) - #expect(fetchUseCaseSpy.queries.map(\.pageSize) == [20, 20]) - #expect(fetchUseCaseSpy.queries.map(\.fetchAllPages) == [true, true]) - #expect(fetchUseCaseSpy.cursors.allSatisfy { $0 == nil }) + let queries = await fetchUseCaseSpy.calledQueries() + let queriesByDueDateFilter = Dictionary( + uniqueKeysWithValues: queries.map { ($0.dueDateFilter, $0) } + ) + let cursors = await fetchUseCaseSpy.calledCursors() + + #expect(queries.count == 2) + #expect(Set(queries.map(\.dueDateFilter)) == Set([.withDueDate, .withoutDueDate])) + #expect(queries.allSatisfy { $0.completionFilter == .incomplete }) + #expect(queriesByDueDateFilter[.withDueDate]?.sortTarget == .dueDate) + #expect(queriesByDueDateFilter[.withDueDate]?.sortOrder == .oldest) + #expect(queriesByDueDateFilter[.withoutDueDate]?.sortTarget == .updatedAt) + #expect(queriesByDueDateFilter[.withoutDueDate]?.sortOrder == .latest) + #expect(queries.map(\.pageSize).allSatisfy { $0 == 20 }) + #expect(queries.map(\.fetchAllPages).allSatisfy { $0 }) + #expect(cursors.count == 2) + #expect(cursors.allSatisfy { $0 == nil }) #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) #expect(adapter.summaryCounts == [ .all: 5, diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift index 5c2326cc..4279811c 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift @@ -13,8 +13,7 @@ import DevLogDomain final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { var pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] var error: Error? - private(set) var queries = [TodoQuery]() - private(set) var cursors = [TodoCursor?]() + private let recorder = TodayFetchTodosUseCaseCallRecorder() init( pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] = [ @@ -26,8 +25,7 @@ final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { } func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { - queries.append(query) - cursors.append(cursor) + await recorder.append(query: query, cursor: cursor) if let error { throw error @@ -35,6 +33,32 @@ final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { return pagesByFilter[query.dueDateFilter] ?? TodoPage(items: [], nextCursor: nil) } + + func calledQueries() async -> [TodoQuery] { + await recorder.queries() + } + + func calledCursors() async -> [TodoCursor?] { + await recorder.cursors() + } +} + +private actor TodayFetchTodosUseCaseCallRecorder { + var recordedQueries = [TodoQuery]() + var recordedCursors = [TodoCursor?]() + + func append(query: TodoQuery, cursor: TodoCursor?) { + recordedQueries.append(query) + recordedCursors.append(cursor) + } + + func queries() -> [TodoQuery] { + recordedQueries + } + + func cursors() -> [TodoCursor?] { + recordedCursors + } } final class TodayFetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { From 64f00d4d5fd7de06be9b565efae2ec33ccbd3366 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:09:28 +0900 Subject: [PATCH 19/21] =?UTF-8?q?refactor:=20=ED=94=BC=EC=BB=A4=20?= =?UTF-8?q?=EC=97=AC=EB=8A=94=20=EC=95=A1=EC=85=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Home/Home/HomeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index a9342842..ca667c3e 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -149,7 +149,7 @@ struct HomeView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.setSheet(.contentPicker(.init()))) + store.send(.setPresentation(.contentPicker, true)) } label: { Image(systemName: "plus") } From f4f54dd707424377d8f3e488421614bb17bd6fe2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:17:12 +0900 Subject: [PATCH 20/21] =?UTF-8?q?refactor:=20view=20/=20store=20=EA=B0=84?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 22 +- .../Sources/Home/Home/HomeFeature.swift | 216 ++++++++++-------- .../Sources/Home/Home/HomeView.swift | 28 +-- .../Home/Home/HomeViewCoordinator.swift | 8 +- .../Tests/Home/HomeFeatureTestSupport.swift | 22 +- 5 files changed, 163 insertions(+), 133 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 30ab98a6..239a0831 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -21,7 +21,7 @@ extension HomeFeature { .publisher { [networkConnectivityUseCase] in networkConnectivityUseCase.observe() .receive(on: DispatchQueue.main) - .map(Action.networkStatusChanged) + .map { .store(.networkStatusChanged($0)) } } .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) } @@ -33,7 +33,7 @@ extension HomeFeature { let preferences = try await fetchPreferencesUseCase.execute() await send(.store(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:))))) } catch { - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.setAlert(isPresented: true, type: .error))) } await send(.loading(.end(target: LoadingTarget.preferences.target, mode: .immediate))) } @@ -50,7 +50,7 @@ extension HomeFeature { .compactMap(RecentTodoItem.init(from:)) await send(.store(.updateRecentTodos(Array(items)))) } catch { - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.setAlert(isPresented: true, type: .error))) } await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate))) } @@ -63,7 +63,7 @@ extension HomeFeature { let pages = try await fetchWebPagesUseCase.execute("") await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) } catch { - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.setAlert(isPresented: true, type: .error))) } await send(.loading(.end(target: LoadingTarget.webPage.target, mode: .immediate))) } @@ -78,7 +78,7 @@ extension HomeFeature { let pages = try await fetchWebPagesUseCase.execute("") await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) } catch { - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.setAlert(isPresented: true, type: .error))) } await send(.loading(.end(target: LoadingTarget.overlay.target, mode: .delayed))) } @@ -89,8 +89,8 @@ extension HomeFeature { do { try await deleteWebPageUseCase.execute(page.url.absoluteString) } catch { - await send(.handleWebPageDeleteFailure(page.id)) - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.handleWebPageDeleteFailure(page.id))) + await send(.store(.setAlert(isPresented: true, type: .error))) } } } @@ -102,9 +102,9 @@ extension HomeFeature { try await addWebPageUseCase.execute(urlString) } catch { if let webPageURL = URL(string: urlString) { - await send(.setWebPageHidden(webPageURL, true)) + await send(.store(.setWebPageHidden(webPageURL, true))) } - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.setAlert(isPresented: true, type: .error))) } } } @@ -114,7 +114,7 @@ extension HomeFeature { do { try await updatePreferencesUseCase.execute(items.map(\.preference)) } catch { - await send(.setAlert(isPresented: true, type: .error)) + await send(.store(.setAlert(isPresented: true, type: .error))) } } } @@ -123,7 +123,7 @@ extension HomeFeature { .run { [clock] send in // iOS 17에서 시트 dismiss 직후 fullScreenCover를 바로 올리지 않도록 하기 위해서 0.1초 딜레이 try await clock.sleep(for: .seconds(0.1)) - await send(.setPresentation(.todoEditor, true)) + await send(.store(.setPresentation(.todoEditor, true))) } .cancellable(id: CancelID.delayedTodoEditor, cancelInFlight: true) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index b2e2355b..04c6b94a 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -50,27 +50,31 @@ struct HomeFeature { case alert(PresentationAction) case sheet(PresentationAction) case fullScreenCover(PresentationAction) - case startObserving - case fetchData - case refreshRecentTodos - case networkStatusChanged(Bool) - case setSheet(SheetState?) - case setPresentation(Presentation, Bool) - case setAlert(isPresented: Bool, type: AlertType? = nil) - case refreshWebPages - case setWebPageHidden(URL, Bool) - case handleWebPageDeleteFailure(URL) - case finishDeleteWebPageToast(String) - case tapTodoCategory(TodoCategory) - case orderTodoCategory([TodoCategoryItem]) - case updateWebPageURLInput(String) - case addWebPage - case deleteWebPage(WebPageItem) - case undoDeleteWebPage + case view(ViewAction) case store(StoreAction) case loading(LoadingFeature.Action) + enum ViewAction: Equatable { + case startObserving + case fetchData + case refreshRecentTodos + case refreshWebPages + case finishDeleteWebPageToast(String) + case tapTodoCategory(TodoCategory) + case orderTodoCategory([TodoCategoryItem]) + case updateWebPageURLInput(String) + case addWebPage + case deleteWebPage(WebPageItem) + case undoDeleteWebPage + } + enum StoreAction: Equatable { + case networkStatusChanged(Bool) + case setSheet(SheetState?) + case setPresentation(Presentation, Bool) + case setAlert(isPresented: Bool, type: AlertType? = nil) + case setWebPageHidden(URL, Bool) + case handleWebPageDeleteFailure(URL) case setTodoCategory([TodoCategoryItem]) case updateRecentTodos([RecentTodoItem]) case updateWebPages([WebPageItem]) @@ -193,82 +197,10 @@ struct HomeFeature { state.sheet = nil case .sheet: break - case .startObserving: - return observeNetworkConnectivityEffect() - case .fetchData: - return .merge( - fetchTodoCategoryPreferencesEffect(), - fetchRecentTodosEffect(), - fetchWebPagesEffect() - ) - case .refreshRecentTodos: - return fetchRecentTodosEffect() - case .networkStatusChanged(let isConnected): - state.isNetworkConnected = isConnected - case .setSheet(let sheet): - state.sheet = sheet - case .setPresentation(let presentation, let isPresented): - Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) - case .setAlert(let isPresented, let type): - Self.setAlert(&state, isPresented: isPresented, type: type) - case .refreshWebPages: - return fetchWebPagesEffect() - case .setWebPageHidden(let webPageURL, let isHidden): - if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { - state.webPages[index].isHidden = isHidden - } - case .handleWebPageDeleteFailure(let webPageURL): - if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { - state.webPages[index].isHidden = false - } else { - state.needsWebPageRefresh = true - } - case .finishDeleteWebPageToast(let urlString): - state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } - if state.deletedWebPageURLString == urlString { - state.deletedWebPageURLString = nil - } - case .tapTodoCategory(let category): - state.selectedTodoCategory = category - state.sheet = nil - return delayedTodoEditorEffect() - case .orderTodoCategory(let preferences): - state.preferences = preferences - state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) - state.sheet = nil - return updateTodoCategoryPreferencesEffect(preferences) - case .updateWebPageURLInput(let text): - state.webPageURLInput = text - case .addWebPage: - guard let normalizedURL = Self.normalizedWebPageURL(state.webPageURLInput) else { - 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): - guard let index = state.webPages.firstIndex(where: { $0.id == page.id }) else { - return .none - } - state.deletedWebPageURLString = page.url.absoluteString - state.webPages[index].isHidden = true - return deleteWebPageEffect(page) - case .undoDeleteWebPage: - guard let urlString = state.deletedWebPageURLString else { return .none } - if let index = state.webPages.firstIndex(where: { $0.url.absoluteString == urlString }) { - state.webPages[index].isHidden = false - } - state.deletedWebPageURLString = nil - return undoDeleteWebPageEffect(urlString) - case .store(.setTodoCategory(let preferences)): - state.preferences = preferences - state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) - case .store(.updateRecentTodos(let todos)): - state.recentTodos = todos - case .store(.updateWebPages(let pages)): - state.webPages = pages - state.needsWebPageRefresh = false + case .view(let action): + return reduce(action, state: &state) + case .store(let action): + return reduce(action, state: &state) case .loading: break } @@ -282,6 +214,104 @@ struct HomeFeature { } } +private extension HomeFeature { + func reduce( + _ action: Action.ViewAction, + state: inout State + ) -> Effect { + switch action { + case .startObserving: + return observeNetworkConnectivityEffect() + case .fetchData: + return .merge( + fetchTodoCategoryPreferencesEffect(), + fetchRecentTodosEffect(), + fetchWebPagesEffect() + ) + case .refreshRecentTodos: + return fetchRecentTodosEffect() + case .refreshWebPages: + return fetchWebPagesEffect() + case .finishDeleteWebPageToast(let urlString): + state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } + if state.deletedWebPageURLString == urlString { + state.deletedWebPageURLString = nil + } + case .tapTodoCategory(let category): + state.selectedTodoCategory = category + state.sheet = nil + return delayedTodoEditorEffect() + case .orderTodoCategory(let preferences): + state.preferences = preferences + state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + state.sheet = nil + return updateTodoCategoryPreferencesEffect(preferences) + case .updateWebPageURLInput(let text): + state.webPageURLInput = text + case .addWebPage: + guard let normalizedURL = Self.normalizedWebPageURL(state.webPageURLInput) else { + 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): + guard let index = state.webPages.firstIndex(where: { $0.id == page.id }) else { + return .none + } + state.deletedWebPageURLString = page.url.absoluteString + state.webPages[index].isHidden = true + return deleteWebPageEffect(page) + case .undoDeleteWebPage: + guard let urlString = state.deletedWebPageURLString else { return .none } + if let index = state.webPages.firstIndex(where: { $0.url.absoluteString == urlString }) { + state.webPages[index].isHidden = false + } + state.deletedWebPageURLString = nil + return undoDeleteWebPageEffect(urlString) + } + + return .none + } + + func reduce( + _ action: Action.StoreAction, + state: inout State + ) -> Effect { + switch action { + case .networkStatusChanged(let isConnected): + state.isNetworkConnected = isConnected + case .setSheet(let sheet): + state.sheet = sheet + case .setPresentation(let presentation, let isPresented): + Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) + case .setAlert(let isPresented, let type): + Self.setAlert(&state, isPresented: isPresented, type: type) + case .setWebPageHidden(let webPageURL, let isHidden): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = isHidden + } + case .handleWebPageDeleteFailure(let webPageURL): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = false + } else { + state.needsWebPageRefresh = true + } + case .setTodoCategory(let preferences): + state.preferences = preferences + state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + case .updateRecentTodos(let todos): + state.recentTodos = todos + case .updateWebPages(let pages): + state.webPages = pages + state.needsWebPageRefresh = false + } + + return .none + } +} + private struct HomeSheetFeature: Reducer { typealias State = HomeFeature.SheetState typealias Action = HomeFeature.Sheet diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index ca667c3e..40b122b1 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -64,7 +64,7 @@ struct HomeView: View { .bold() Spacer() Button(action: { - store.send(.setSheet(.reorderTodo)) + store.send(.store(.setSheet(.reorderTodo))) }) { Image(systemName: "ellipsis") .font(.title2) @@ -110,7 +110,7 @@ struct HomeView: View { .id(UUID()) // id 부여를 통해 렌더링 강제 } else if store.needsWebPageRefresh { Button { - store.send(.refreshWebPages) + store.send(.view(.refreshWebPages)) } label: { HStack { Spacer() @@ -149,7 +149,7 @@ struct HomeView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.setPresentation(.contentPicker, true)) + store.send(.store(.setPresentation(.contentPicker, true))) } label: { Image(systemName: "plus") } @@ -160,7 +160,7 @@ struct HomeView: View { } ToolbarItemGroup(placement: .topBarTrailing) { Button { - store.send(.setPresentation(.searchView, true)) + store.send(.store(.setPresentation(.searchView, true))) } label: { Image(systemName: "magnifyingglass") } @@ -221,7 +221,7 @@ struct HomeView: View { "https://", text: Binding( get: { store.webPageURLInput }, - set: { store.send(.updateWebPageURLInput($0)) } + set: { store.send(.view(.updateWebPageURLInput($0))) } ) ) .textInputAutocapitalization(.never) @@ -236,7 +236,7 @@ struct HomeView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "home_add")) { - store.send(.addWebPage) + store.send(.view(.addWebPage)) } } } @@ -258,7 +258,7 @@ struct HomeView: View { CategoryManageView( preferences: store.preferences, onDismiss: { array in - store.send(.orderTodoCategory(array), animation: .default) + store.send(.view(.orderTodoCategory(array)), animation: .default) } ) } @@ -272,8 +272,8 @@ struct HomeView: View { TodoEditorView( store: coordinator.makeTodoEditorStore(category: selectedCategory), onCreateSuccess: { - store.send(.setPresentation(.todoEditor, false)) - store.send(.fetchData) + store.send(.store(.setPresentation(.todoEditor, false))) + store.send(.view(.fetchData)) } ) } @@ -344,7 +344,7 @@ struct HomeView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - store.send(.deleteWebPage(item)) + store.send(.view(.deleteWebPage(item))) presentDeleteWebPageToast(item.url.absoluteString) } label: { Label(String(localized: "common_delete"), systemImage: "trash") @@ -375,13 +375,13 @@ struct HomeView: View { private func openTodoEditor(for todoCategory: TodoCategory) { if isiOSAppOnMac { - store.send(.setPresentation(.contentPicker, false)) + store.send(.store(.setPresentation(.contentPicker, false))) openWindow( id: TodoEditorWindowValue.sceneId, value: TodoEditorWindowValue(todoCategory: todoCategory, source: .home) ) } else { - store.send(.tapTodoCategory(todoCategory)) + store.send(.view(.tapTodoCategory(todoCategory))) } } @@ -393,10 +393,10 @@ struct HomeView: View { font: .caption, multilineTextAlignment: .center, action: { - store.send(.undoDeleteWebPage) + store.send(.view(.undoDeleteWebPage)) }, onDismiss: { - store.send(.finishDeleteWebPageToast(urlString)) + store.send(.view(.finishDeleteWebPageToast(urlString))) } ) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 329e2586..afca82f3 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -41,15 +41,15 @@ final class HomeViewCoordinator { $0.networkConnectivityUseCase = container.resolve(ObserveNetworkConnectivityUseCase.self) $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) } - self.store.send(.startObserving) + self.store.send(.view(.startObserving)) } func fetchData() { - store.send(.fetchData) + store.send(.view(.fetchData)) } func refreshRecentTodos() { - store.send(.refreshRecentTodos) + store.send(.view(.refreshRecentTodos)) } func bindTodoMutationEvent() { @@ -77,7 +77,7 @@ final class HomeViewCoordinator { .sink { [weak self] submit in guard case .create(let value) = submit, value.matchesCreate(source: .home) else { return } - self?.store.send(.fetchData) + self?.store.send(.view(.fetchData)) } .store(in: &cancellables) } diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index 67048aff..8a3367f8 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -79,12 +79,12 @@ struct HomeStoreTestAdapter { } func startObserving() async { - await store.send(.startObserving) + await store.send(.view(.startObserving)) await drainReceivedActions() } func fetchData() async { - await store.send(.fetchData) + await store.send(.view(.fetchData)) await drainReceivedActions() } @@ -94,46 +94,46 @@ struct HomeStoreTestAdapter { } func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { - await store.send(.setPresentation(presentation, isPresented)) + await store.send(.store(.setPresentation(presentation, isPresented))) } func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async { - await store.send(.setAlert(isPresented: isPresented, type: type)) + await store.send(.store(.setAlert(isPresented: isPresented, type: type))) await drainReceivedActions() } func tapTodoCategory(_ category: TodoCategory) async { - await store.send(.tapTodoCategory(category)) + await store.send(.view(.tapTodoCategory(category))) await clock.advance(by: .seconds(1)) await settle() } func orderTodoCategory(_ items: [TodoCategoryItem]) async { - await store.send(.orderTodoCategory(items)) + await store.send(.view(.orderTodoCategory(items))) await drainReceivedActions() } func updateWebPageURLInput(_ input: String) async { - await store.send(.updateWebPageURLInput(input)) + await store.send(.view(.updateWebPageURLInput(input))) } func addWebPage() async { - await store.send(.addWebPage) + await store.send(.view(.addWebPage)) await drainReceivedActions() } func deleteWebPage(_ page: WebPageItem) async { - await store.send(.deleteWebPage(page)) + await store.send(.view(.deleteWebPage(page))) await drainReceivedActions() } func undoDeleteWebPage() async { - await store.send(.undoDeleteWebPage) + await store.send(.view(.undoDeleteWebPage)) await drainReceivedActions() } func finishDeleteWebPageToast(_ urlString: String) async { - await store.send(.finishDeleteWebPageToast(urlString)) + await store.send(.view(.finishDeleteWebPageToast(urlString))) } func drainReceivedActions() async { From aad35e4dff7bb8e1d400f8513f327953da65bb20 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:28:46 +0900 Subject: [PATCH 21/21] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=EB=90=98=EC=96=B4=20=EC=9E=88=EB=8D=98=20Sto?= =?UTF-8?q?re=EB=93=A4=EC=9D=98=20reduce()=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=8B=B1=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/List/TodoListFeature.swift | 202 ++++++++++-------- .../Sources/Home/List/TodoListView.swift | 24 +-- .../PushNotificationListFeature.swift | 131 +++++++----- .../PushNotificationListView.swift | 28 +-- .../PushNotificationListViewCoordinator.swift | 2 +- .../WindowGroup/TodoWindowCoordinator.swift | 2 +- .../Home/TodoListFeatureTestDoubles.swift | 18 +- .../PushNotificationListTestSupport.swift | 22 +- 8 files changed, 237 insertions(+), 192 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index 7c5f77e9..d5af1df8 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -63,20 +63,24 @@ struct TodoListFeature { case alert(PresentationAction) case fullScreenCover(PresentationAction) case binding(BindingAction) - case refresh - case setFullScreenCover(FullScreenCoverState?) - case swipeTodo(TodoListItem) - case resetFilters - case finishDeleteToast(String) - case tapToggleCompleted(TodoListItem) - case tapTogglePinned(TodoListItem) - case undoDelete - case onAppear - case loadNextPage + case view(ViewAction) case store(StoreAction) case loading(LoadingFeature.Action) + enum ViewAction: Equatable { + case refresh + case swipeTodo(TodoListItem) + case resetFilters + case finishDeleteToast(String) + case tapToggleCompleted(TodoListItem) + case tapTogglePinned(TodoListItem) + case undoDelete + case onAppear + case loadNextPage + } + enum StoreAction: Equatable { + case setFullScreenCover(FullScreenCoverState?) case setAlert(Bool) case applySearchQuery(String) case fetchSearchResults([TodoListItem]) @@ -111,7 +115,36 @@ struct TodoListFeature { } BindingReducer() Reduce { state, action in - reduce(action, state: &state) + switch action { + case .alert: + break + case .fullScreenCover(.dismiss): + state.fullScreenCover = nil + case .fullScreenCover: + break + case .binding(\.searchText): + return setSearchTextEffect(state: &state) + case .binding(\.isSearching): + guard !state.isSearching else { break } + state.searchText = "" + state.searchResults = [] + state.showAllSearchResults = false + return cancelSearchEffect() + case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), + .binding(\.query.completionFilter): + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .binding: + break + case .view(let action): + return reduce(action, state: &state) + case .store(let action): + return reduce(action, state: &state) + case .loading: + break + } + + return .none } .ifLet(\.$alert, action: \.alert) } @@ -165,84 +198,6 @@ private enum TodoListUndoDeleteTodoUseCaseKey: DependencyKey { } private extension TodoListFeature { - func reduce(_ action: Action, state: inout State) -> Effect { - switch action { - case .alert: - break - case .fullScreenCover(.dismiss): - state.fullScreenCover = nil - case .fullScreenCover: - break - case .binding(\.searchText): - return setSearchTextEffect(state: &state) - case .binding(\.isSearching): - guard !state.isSearching else { break } - state.searchText = "" - state.searchResults = [] - state.showAllSearchResults = false - return cancelSearchEffect() - case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), - .binding(\.query.completionFilter): - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .binding: - break - case .refresh, .onAppear: - return fetchEffect(query: state.query, cursor: nil) - case .store(.setAlert(let value)): - Self.setAlert(&state, isPresented: value) - case .setFullScreenCover(let cover): - state.fullScreenCover = cover - case .swipeTodo(let todo): - return swipeTodoEffect(todo, state: &state) - case .resetFilters: - state.query = TodoQuery(categoryId: state.category.storageValue) - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .finishDeleteToast(let todoId): - state.todos.removeAll { $0.id == todoId && $0.isHidden } - state.searchResults.removeAll { $0.id == todoId && $0.isHidden } - if state.undoTodoId == todoId { - state.undoTodoId = nil - } - case .tapToggleCompleted(let todo): - return toggleCompletedEffect(todo) - case .tapTogglePinned(let todo): - return togglePinnedEffect(todo) - case .undoDelete: - guard let undoTodoId = state.undoTodoId else { return .none } - Self.setTodoHidden(&state, todoId: undoTodoId, isHidden: false) - state.undoTodoId = nil - return undoDeleteEffect(undoTodoId) - case .loadNextPage: - guard state.hasMore, !state.isLoading else { return .none } - return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) - case .store(.applySearchQuery(let query)): - return applySearchQueryEffect(query, state: &state) - case .store(.fetchSearchResults(let items)): - state.searchResults = items - case .store(.didToggleCompleted(let todo)), .store(.didTogglePinned(let todo)): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - state.todos[index] = todo - } - case .store(.setTodoHidden(let todoId, let isHidden)): - Self.setTodoHidden(&state, todoId: todoId, isHidden: isHidden) - case .store(.appendTodos(let todos, let nextCursor)): - state.todos.append(contentsOf: todos) - state.nextCursor = nextCursor - case .store(.resetPagination): - state.todos = [] - state.nextCursor = nil - state.hasMore = false - case .store(.setHasMore(let value)): - state.hasMore = value - case .loading: - break - } - - return .none - } - func fetchEffect( query: TodoQuery, cursor: TodoCursor?, @@ -316,4 +271,73 @@ private extension TodoListFeature { .cancellable(id: CancelID.debounce, cancelInFlight: true) ) } + + func reduce( + _ action: Action.ViewAction, + state: inout State + ) -> Effect { + switch action { + case .refresh, .onAppear: + return fetchEffect(query: state.query, cursor: nil) + case .swipeTodo(let todo): + return swipeTodoEffect(todo, state: &state) + case .resetFilters: + state.query = TodoQuery(categoryId: state.category.storageValue) + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .finishDeleteToast(let todoId): + state.todos.removeAll { $0.id == todoId && $0.isHidden } + state.searchResults.removeAll { $0.id == todoId && $0.isHidden } + if state.undoTodoId == todoId { + state.undoTodoId = nil + } + case .tapToggleCompleted(let todo): + return toggleCompletedEffect(todo) + case .tapTogglePinned(let todo): + return togglePinnedEffect(todo) + case .undoDelete: + guard let undoTodoId = state.undoTodoId else { return .none } + Self.setTodoHidden(&state, todoId: undoTodoId, isHidden: false) + state.undoTodoId = nil + return undoDeleteEffect(undoTodoId) + case .loadNextPage: + guard state.hasMore, !state.isLoading else { return .none } + return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) + } + + return .none + } + + func reduce( + _ action: Action.StoreAction, + state: inout State + ) -> Effect { + switch action { + case .setFullScreenCover(let cover): + state.fullScreenCover = cover + case .setAlert(let value): + Self.setAlert(&state, isPresented: value) + case .applySearchQuery(let query): + return applySearchQueryEffect(query, state: &state) + case .fetchSearchResults(let items): + state.searchResults = items + case .didToggleCompleted(let todo), .didTogglePinned(let todo): + if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { + state.todos[index] = todo + } + case .setTodoHidden(let todoId, let isHidden): + Self.setTodoHidden(&state, todoId: todoId, isHidden: isHidden) + case .appendTodos(let todos, let nextCursor): + state.todos.append(contentsOf: todos) + state.nextCursor = nextCursor + case .resetPagination: + state.todos = [] + state.nextCursor = nil + state.hasMore = false + case .setHasMore(let value): + state.hasMore = value + } + + return .none + } } diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index f6d767b4..9e5f9bf2 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -80,7 +80,7 @@ struct TodoListView: View { } .background(NavigationBarConfigurator()) .background(Color(.systemGroupedBackground)) - .task { store.send(.onAppear) } + .task { store.send(.view(.onAppear)) } } @ViewBuilder @@ -118,18 +118,18 @@ struct TodoListView: View { .onAppear { let lastID = visibleTodos.last?.id if todo.id == lastID, store.state.hasMore { - store.send(.loadNextPage) + store.send(.view(.loadNextPage)) } } .swipeActions(edge: .leading) { Button(action: { - store.send(.tapTogglePinned(todo)) + store.send(.view(.tapTogglePinned(todo))) }) { Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") } .tint(Color.orange) Button { - store.send(.tapToggleCompleted(todo)) + store.send(.view(.tapToggleCompleted(todo))) } label: { Image(systemName: todo.isCompleted ? "arrow.uturn.backward" : "checkmark") } @@ -137,7 +137,7 @@ struct TodoListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive, action: { - store.send(.swipeTodo(todo)) + store.send(.view(.swipeTodo(todo))) presentDeleteTodoToast(todo.id) }) { Image(systemName: "trash") @@ -171,7 +171,7 @@ struct TodoListView: View { } .offset(y: headerOffset) } - .refreshable { store.send(.refresh) } + .refreshable { store.send(.view(.refresh)) } .scrollDisabled(visibleTodos.isEmpty || store.state.isLoading) if store.state.isLoading { @@ -212,8 +212,8 @@ struct TodoListView: View { $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) }, onCreateSuccess: { - store.send(.setFullScreenCover(nil)) - store.send(.refresh) + store.send(.store(.setFullScreenCover(nil))) + store.send(.view(.refresh)) } ) } @@ -226,7 +226,7 @@ struct TodoListView: View { value: TodoEditorWindowValue(todoCategory: store.category, source: .list) ) } else { - store.send(.setFullScreenCover(.editor)) + store.send(.store(.setFullScreenCover(.editor))) } } @@ -236,10 +236,10 @@ struct TodoListView: View { systemImage: "arrow.uturn.left", duration: 5, action: { - store.send(.undoDelete) + store.send(.view(.undoDelete)) }, onDismiss: { - store.send(.finishDeleteToast(todoId)) + store.send(.view(.finishDeleteToast(todoId))) } ) } @@ -310,7 +310,7 @@ struct TodoListView: View { ) ) Button(role: .destructive) { - store.send(.resetFilters) + store.send(.view(.resetFilters)) } label: { Text(String(localized: "todo_list_clear_filters")) } diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index d37d0e06..911f3340 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -53,20 +53,24 @@ struct PushNotificationListFeature { case alert(PresentationAction) case sheet(PresentationAction) case binding(BindingAction) - case fetchNotifications - case loadNextPage - case deleteNotification(PushNotificationItem) - case toggleRead(PushNotificationItem) - case undoDelete - case finishDeleteToast(String) - case toggleSortOption - case toggleUnreadOnly - case resetFilters - case selectNotification(String?) - case syncSheetPresentation(isCompactLayout: Bool) + case view(ViewAction) case store(StoreAction) case loading(LoadingFeature.Action) + enum ViewAction: Equatable { + case fetchNotifications + case loadNextPage + case deleteNotification(PushNotificationItem) + case toggleRead(PushNotificationItem) + case undoDelete + case finishDeleteToast(String) + case toggleSortOption + case toggleUnreadOnly + case resetFilters + case selectNotification(String?) + case syncSheetPresentation(isCompactLayout: Bool) + } + enum Sheet: Equatable { case tapCloseButton } @@ -101,7 +105,29 @@ struct PushNotificationListFeature { } BindingReducer() Reduce { state, action in - reduce(action, state: &state) + switch action { + case .alert: + break + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + state.selectedNotificationId = nil + state.selectedTodoId = nil + case .sheet: + break + case .binding(\.query.timeFilter): + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .binding: + break + case .view(let action): + return reduce(action, state: &state) + case .store(let action): + return reduce(action, state: &state) + case .loading: + break + } + + return .none } .ifLet(\.$alert, action: \.alert) .ifLet(\.$sheet, action: \.sheet) { @@ -121,23 +147,10 @@ private struct PushNotificationListSheetFeature: Reducer { private extension PushNotificationListFeature { func reduce( - _ action: Action, + _ action: Action.ViewAction, state: inout State ) -> Effect { switch action { - case .alert: - break - case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): - state.sheet = nil - state.selectedNotificationId = nil - state.selectedTodoId = nil - case .sheet: - break - case .binding(\.query.timeFilter): - state.nextCursor = nil - return refreshForQueryChangeEffect(query: state.query) - case .binding: - break case .fetchNotifications: state.nextCursor = nil return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0) @@ -170,32 +183,6 @@ private extension PushNotificationListFeature { if state.undoNotificationId == notificationId { state.undoNotificationId = nil } - case .store(.setAlert): - state.alert = Self.alertState() - case .store(.appendNotifications(let notifications, let nextCursor)): - state.notifications.append(contentsOf: Self.mergedHiddenNotifications( - currentNotifications: state.notifications, - incomingNotifications: notifications - )) - state.nextCursor = nextCursor - case .store(.resetPagination): - state.notifications = [] - state.nextCursor = nil - case .store(.setHasMore(let value)): - state.hasMore = value - case .store(.syncNotifications(let notifications, let nextCursor, let hasMore)): - state.notifications = Self.mergedHiddenNotifications( - currentNotifications: state.notifications, - incomingNotifications: notifications - ) - state.nextCursor = nextCursor - state.hasMore = hasMore - case .store(.setNotificationHidden(let notificationId, let isHidden)): - Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) - case .store(.setNotificationRead(let notificationId, let isRead)): - if let index = state.notifications.firstIndex(where: { $0.id == notificationId }) { - state.notifications[index].isRead = isRead - } case .toggleSortOption: state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest state.nextCursor = nil @@ -229,10 +216,44 @@ private extension PushNotificationListFeature { } else { state.sheet = nil } - case .store(.observeNotifications(let query, let limit)): + } + + return .none + } + + func reduce( + _ action: Action.StoreAction, + state: inout State + ) -> Effect { + switch action { + case .setAlert: + state.alert = Self.alertState() + case .appendNotifications(let notifications, let nextCursor): + state.notifications.append(contentsOf: Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + )) + state.nextCursor = nextCursor + case .resetPagination: + state.notifications = [] + state.nextCursor = nil + case .setHasMore(let value): + state.hasMore = value + case .syncNotifications(let notifications, let nextCursor, let hasMore): + state.notifications = Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + ) + state.nextCursor = nextCursor + state.hasMore = hasMore + case .setNotificationHidden(let notificationId, let isHidden): + Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) + case .setNotificationRead(let notificationId, let isRead): + if let index = state.notifications.firstIndex(where: { $0.id == notificationId }) { + state.notifications[index].isRead = isRead + } + case .observeNotifications(let query, let limit): return observeNotificationsEffect(query: query, limit: limit) - case .loading: - break } return .none diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 27f39efc..d6810f7c 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -38,7 +38,7 @@ struct PushNotificationListView: View { headerOffset = max(0, -offset) } .safeAreaInset(edge: .top) { safeAreaHeader } - .refreshable { store.send(.fetchNotifications) } + .refreshable { store.send(.view(.fetchNotifications)) } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) } @@ -47,10 +47,10 @@ struct PushNotificationListView: View { sheetContent(store) } .task(id: isCompactLayout) { - store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) } .onChange(of: store.selectedTodoId?.id, initial: true) { - store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) } .overlay { if store.isLoading { @@ -96,7 +96,7 @@ struct PushNotificationListView: View { ) -> some View { if isCompactLayout { Button { - store.send(.selectNotification(notification.id)) + store.send(.view(.selectNotification(notification.id))) } label: { notificationRowContent(notification, index: index, notifications: notifications) } @@ -104,11 +104,11 @@ struct PushNotificationListView: View { } else { notificationRowContent(notification, index: index, notifications: notifications) .onTapGesture { - store.send(.selectNotification(notification.id)) + store.send(.view(.selectNotification(notification.id))) } .accessibilityAddTraits(.isButton) .accessibilityAction { - store.send(.selectNotification(notification.id)) + store.send(.view(.selectNotification(notification.id))) } } } @@ -125,7 +125,7 @@ struct PushNotificationListView: View { .onAppear { let lastId = notifications.last?.id if notification.id == lastId, store.hasMore { - store.send(.loadNextPage) + store.send(.view(.loadNextPage)) } } .overlay(alignment: .top) { @@ -168,7 +168,7 @@ struct PushNotificationListView: View { ) ) Button(role: .destructive) { - store.send(.resetFilters) + store.send(.view(.resetFilters)) } label: { Text(String(localized: "push_clear_all_filters")) } @@ -183,7 +183,7 @@ struct PushNotificationListView: View { Button { DispatchQueue.main.async { - store.send(.toggleSortOption) + store.send(.view(.toggleSortOption)) } } label: { let condition = store.query.sortOrder == .oldest @@ -218,7 +218,7 @@ struct PushNotificationListView: View { Button { DispatchQueue.main.async { - store.send(.toggleUnreadOnly) + store.send(.view(.toggleUnreadOnly)) } } label: { let condition = store.query.unreadOnly @@ -306,7 +306,7 @@ struct PushNotificationListView: View { } .swipeActions(edge: .leading) { Button { - store.send(.toggleRead(item)) + store.send(.view(.toggleRead(item))) } label: { Image(systemName: "checkmark.circle\(item.isRead ? ".badge.xmark" : "")") .tint(.blue) @@ -316,7 +316,7 @@ struct PushNotificationListView: View { Button( role: .destructive, action: { - store.send(.deleteNotification(item)) + store.send(.view(.deleteNotification(item))) presentDeleteNotificationToast(item.id) } ) { @@ -391,10 +391,10 @@ struct PushNotificationListView: View { multilineTextAlignment: .center, lineLimit: 3, action: { - store.send(.undoDelete) + store.send(.view(.undoDelete)) }, onDismiss: { - store.send(.finishDeleteToast(notificationId)) + store.send(.view(.finishDeleteToast(notificationId))) } ) } diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift index 28e34404..dec844f0 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -38,7 +38,7 @@ final class PushNotificationListViewCoordinator { } func fetchData() { - store.send(.fetchNotifications) + store.send(.view(.fetchNotifications)) } func makeTodoDetailStore(todoId: String) -> StoreOf { diff --git a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift index 22576937..1a7343f6 100644 --- a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift +++ b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift @@ -84,7 +84,7 @@ final class TodoWindowCoordinator { case .create(let value): if let listStore, value.matchesCreate(category: listStore.category, source: .list) { - listStore.send(.refresh) + listStore.send(.view(.refresh)) } case .update(let value, let todo): if let detailStore, diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index f6c9196f..8ccf2e91 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -54,12 +54,12 @@ final class TodoListStoreTestAdapter { } func onAppear() async { - await store.send(.onAppear) + await store.send(.view(.onAppear)) await drainReceivedActions() } func loadNextPage() async { - await store.send(.loadNextPage) + await store.send(.view(.loadNextPage)) await drainReceivedActions() } @@ -84,7 +84,7 @@ final class TodoListStoreTestAdapter { } func resetFilters() async { - await store.send(.resetFilters) + await store.send(.view(.resetFilters)) await drainReceivedActions() } @@ -117,7 +117,7 @@ final class TodoListStoreTestAdapter { } func setFullScreenCover(_ cover: TodoListFeature.FullScreenCoverState?) async { - await store.send(.setFullScreenCover(cover)) + await store.send(.store(.setFullScreenCover(cover))) } func dismissFullScreenCover() async { @@ -125,26 +125,26 @@ final class TodoListStoreTestAdapter { } func swipeTodo(_ todo: TodoListItem) async { - await store.send(.swipeTodo(todo)) + await store.send(.view(.swipeTodo(todo))) await drainReceivedActions() } func undoDelete() async { - await store.send(.undoDelete) + await store.send(.view(.undoDelete)) await drainReceivedActions() } func finishDeleteToast(_ todoId: String) async { - await store.send(.finishDeleteToast(todoId)) + await store.send(.view(.finishDeleteToast(todoId))) } func tapToggleCompleted(_ todo: TodoListItem) async { - await store.send(.tapToggleCompleted(todo)) + await store.send(.view(.tapToggleCompleted(todo))) await drainReceivedActions() } func tapTogglePinned(_ todo: TodoListItem) async { - await store.send(.tapTogglePinned(todo)) + await store.send(.view(.tapTogglePinned(todo))) await drainReceivedActions() } diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index 338f28e2..52358f5d 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -149,17 +149,17 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { } func fetchNotifications() async { - await store.send(.fetchNotifications) + await store.send(.view(.fetchNotifications)) await drainReceivedActions() } func loadNextPage() async { - await store.send(.loadNextPage) + await store.send(.view(.loadNextPage)) await drainReceivedActions() } func toggleSortOption() async { - await store.send(.toggleSortOption) + await store.send(.view(.toggleSortOption)) await drainReceivedActions() } @@ -169,42 +169,42 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { } func toggleUnreadOnly() async { - await store.send(.toggleUnreadOnly) + await store.send(.view(.toggleUnreadOnly)) await drainReceivedActions() } func resetFilters() async { - await store.send(.resetFilters) + await store.send(.view(.resetFilters)) await drainReceivedActions() } func selectNotification(_ notificationId: String?) async { - await store.send(.selectNotification(notificationId)) + await store.send(.view(.selectNotification(notificationId))) await drainReceivedActions() } func toggleRead(_ item: PushNotificationItem) async { - await store.send(.toggleRead(item)) + await store.send(.view(.toggleRead(item))) await drainReceivedActions() } func deleteNotification(_ item: PushNotificationItem) async { - await store.send(.deleteNotification(item)) + await store.send(.view(.deleteNotification(item))) presentDeleteNotificationToast(item.id) await drainReceivedActions() } func undoDelete() async { - await store.send(.undoDelete) + await store.send(.view(.undoDelete)) await drainReceivedActions() } func finishDeleteToast(_ notificationId: String) async { - await store.send(.finishDeleteToast(notificationId)) + await store.send(.view(.finishDeleteToast(notificationId))) } func syncSheetPresentation(isCompactLayout: Bool) async { - await store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + await store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) } func dismissSheet() async {