From a4fa05d53d4614d2c53e61d9ecee8182bb179376 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:21:37 +0900 Subject: [PATCH 1/9] =?UTF-8?q?test:=20TodoListViewModel=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/TodoListFeatureTestDoubles.swift | 262 ++++++++++++++++++ .../Tests/Home/TodoListFeatureTests.swift | 214 ++++++++++++++ 2 files changed, 476 insertions(+) create mode 100644 Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift create mode 100644 Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift new file mode 100644 index 00000000..42712aa3 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -0,0 +1,262 @@ +// +// TodoListFeatureTestDoubles.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Foundation +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +final class TodoListStoreTestAdapter { + private let viewModel: TodoListViewModel + + var todos: [TodoListItem] { viewModel.state.todos } + var searchText: String { viewModel.state.searchText } + var searchResults: [TodoListItem] { viewModel.state.searchResults } + var isSearching: Bool { viewModel.state.isSearching } + var showAllSearchResults: Bool { viewModel.state.showAllSearchResults } + var query: TodoQuery { viewModel.state.query } + var isLoading: Bool { viewModel.state.isLoading } + var hasMore: Bool { viewModel.state.hasMore } + var showAlert: Bool { viewModel.state.showAlert } + var alertTitle: String { viewModel.state.alertTitle } + var alertMessage: String { viewModel.state.alertMessage } + var appliedFilterCount: Int { viewModel.appliedFilterCount } + + init( + fetchUseCase: FetchTodosUseCase = TodoListFetchTodosUseCaseSpy(), + fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodoListFetchTodoByIdUseCaseSpy(), + upsertUseCase: UpsertTodoUseCase = TodoListUpsertTodoUseCaseSpy(), + deleteUseCase: DeleteTodoUseCase = TodoListDeleteTodoUseCaseSpy(), + undoDeleteUseCase: UndoDeleteTodoUseCase = TodoListUndoDeleteTodoUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodoListTrackAnalyticsEventUseCaseSpy(), + category: TodoCategory = .system(.feature) + ) { + viewModel = TodoListViewModel( + fetchTodosUseCase: fetchUseCase, + fetchTodoByIdUseCase: fetchTodoByIdUseCase, + upsertTodoUseCase: upsertUseCase, + deleteTodoUseCase: deleteUseCase, + undoDeleteTodoUseCase: undoDeleteUseCase, + trackAnalyticsEventUseCase: trackAnalyticsEventUseCase, + category: category + ) + } + + func onAppear() async { + viewModel.send(.onAppear) + } + + func loadNextPage() async { + viewModel.send(.loadNextPage) + } + + func setSortTarget(_ target: TodoQuery.SortTarget) async { + viewModel.send(.setSortTarget(target)) + } + + func setSortOrder(_ order: TodoQuery.SortOrder) async { + viewModel.send(.setSortOrder(order)) + } + + func togglePinnedOnly() async { + viewModel.send(.togglePinnedOnly) + } + + func setCompletionFilter(_ filter: TodoQuery.CompletionFilter) async { + viewModel.send(.setCompletionFilter(filter)) + } + + func resetFilters() async { + viewModel.send(.resetFilters) + } + + func setSearchText(_ text: String) async { + viewModel.send(.setSearchText(text)) + } + + func setSearchResults(_ results: [TodoListItem]) async { + viewModel.send(.fetchSearchResults(results)) + } + + func setIsSearching(_ value: Bool) async { + viewModel.send(.setIsSearching(value)) + } + + func setShowAllSearchResults(_ value: Bool) async { + viewModel.send(.setShowAllSearchResults(value)) + } + + func appendTodos(_ todos: [TodoListItem]) async { + viewModel.send(.appendTodos(todos, nextCursor: nil)) + } + + func swipeTodo(_ todo: TodoListItem) async { + viewModel.send(.swipeTodo(todo)) + } + + func undoDelete() async { + viewModel.send(.undoDelete) + } + + func finishDeleteToast(_ todoId: String) async { + viewModel.send(.finishDeleteToast(todoId)) + } + + func tapToggleCompleted(_ todo: TodoListItem) async { + viewModel.send(.tapToggleCompleted(todo)) + } + + func tapTogglePinned(_ todo: TodoListItem) async { + viewModel.send(.tapTogglePinned(todo)) + } +} + +final class TodoListFetchTodosUseCaseSpy: FetchTodosUseCase { + var pages: [TodoPage] + var error: Error? + private(set) var queries = [TodoQuery]() + private(set) var cursors = [TodoCursor?]() + + init(pages: [TodoPage] = [TodoPage(items: [], nextCursor: nil)]) { + self.pages = pages + } + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + cursors.append(cursor) + + if let error { + throw error + } + + if pages.count <= queries.count - 1 { + return pages.last ?? TodoPage(items: [], nextCursor: nil) + } + + return pages[queries.count - 1] + } +} + +final class TodoListFetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { + var todos: [Todo] + var error: Error? + private(set) var todoIds = [String]() + + init(todos: [Todo] = []) { + self.todos = todos + } + + func execute(_ todoId: String) async throws -> Todo { + todoIds.append(todoId) + + if let error { + throw error + } + + return todos.first { $0.id == todoId } ?? makeTodoListTodo(id: todoId) + } +} + +final class TodoListUpsertTodoUseCaseSpy: UpsertTodoUseCase { + var error: Error? + private(set) var todos = [Todo]() + private(set) var todoDrafts = [TodoDraft]() + + func execute(_ todo: Todo) async throws { + todos.append(todo) + + if let error { + throw error + } + } + + func execute(_ todoDraft: TodoDraft) async throws { + todoDrafts.append(todoDraft) + + if let error { + throw error + } + } +} + +final class TodoListDeleteTodoUseCaseSpy: DeleteTodoUseCase { + var error: Error? + private(set) var todoIds = [String]() + + func execute(_ todoId: String) async throws { + todoIds.append(todoId) + + if let error { + throw error + } + } +} + +final class TodoListUndoDeleteTodoUseCaseSpy: UndoDeleteTodoUseCase { + var error: Error? + private(set) var todoIds = [String]() + + func execute(_ todoId: String) async throws { + todoIds.append(todoId) + + if let error { + throw error + } + } +} + +final class TodoListTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private(set) var events = [AnalyticsEvent]() + var hasTrackedTodoComplete: Bool { + events.contains { + guard case .todoComplete = $0 else { return false } + return true + } + } + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +enum TodoListTestError: Error { + case failure +} + +func makeTodoListTodo( + id: String = "todo-1", + isPinned: Bool = false, + isCompleted: Bool = false, + number: Int = 1, + title: String = "Todo" +) -> Todo { + Todo( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: false, + number: number, + title: title, + content: "content", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + completedAt: isCompleted ? Date(timeIntervalSince1970: 0) : nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature) + ) +} + +func makeTodoListCursor(documentID: String) -> TodoCursor { + TodoCursor( + primarySortDate: Date(timeIntervalSince1970: 0), + secondarySortDate: Date(timeIntervalSince1970: 0), + documentID: documentID + ) +} diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift new file mode 100644 index 00000000..4e3e1f71 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift @@ -0,0 +1,214 @@ +// +// TodoListFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import Foundation +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct TodoListFeatureTests { + @Test("onAppear는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") + func onAppear는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async { + let todos = (0..<20).map { makeTodoListTodo(id: "todo-\($0)", number: $0) } + let cursor = makeTodoListCursor(documentID: "cursor-1") + let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ + TodoPage(items: todos, nextCursor: cursor) + ]) + let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.onAppear() + + await waitUntil { + adapter.todos.count == 20 + } + + #expect(fetchSpy.queries.map(\.categoryId) == ["feature"]) + #expect(fetchSpy.cursors.map { $0?.documentID } == [nil]) + #expect(adapter.todos == todos.compactMap(TodoListItem.init(from:))) + #expect(adapter.hasMore) + } + + @Test("loadNextPage는 다음 커서로 조회한 Todo를 기존 목록 뒤에 추가한다") + func loadNextPage는_다음_커서로_조회한_Todo를_기존_목록_뒤에_추가한다() async { + let firstTodos = (0..<20).map { makeTodoListTodo(id: "todo-\($0)", number: $0) } + let nextTodo = makeTodoListTodo(id: "todo-next", number: 20) + let cursor = makeTodoListCursor(documentID: "cursor-1") + let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ + TodoPage(items: firstTodos, nextCursor: cursor), + TodoPage(items: [nextTodo], nextCursor: nil) + ]) + let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.onAppear() + + await waitUntil { + adapter.todos.count == 20 + } + + await adapter.loadNextPage() + + await waitUntil { + adapter.todos.count == 21 + } + + #expect(fetchSpy.cursors.map { $0?.documentID } == [nil, "cursor-1"]) + #expect(adapter.todos.last == TodoListItem(from: nextTodo)) + #expect(!adapter.hasMore) + } + + @Test("필터와 정렬 액션은 query와 적용 필터 수를 갱신한다") + func 필터와_정렬_액션은_query와_적용_필터_수를_갱신한다() async { + let adapter = TodoListStoreTestAdapter() + + await adapter.setSortTarget(.updatedAt) + await adapter.setSortOrder(.oldest) + await adapter.togglePinnedOnly() + await adapter.setCompletionFilter(.completed) + + #expect(adapter.query.sortTarget == .updatedAt) + #expect(adapter.query.sortOrder == .oldest) + #expect(adapter.query.isPinned == true) + #expect(adapter.query.completionFilter == .completed) + #expect(adapter.appliedFilterCount == 4) + + await adapter.resetFilters() + + #expect(adapter.query == TodoQuery(categoryId: "feature")) + #expect(adapter.appliedFilterCount == 0) + } + + @Test("setSearchText는 표시 범위를 초기화하고 디바운스 후 검색 결과를 반영한다") + func setSearchText는_표시_범위를_초기화하고_디바운스_후_검색_결과를_반영한다() async { + let todo = makeTodoListTodo(id: "todo-search", title: "Swift") + let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ + TodoPage(items: [todo], nextCursor: nil) + ]) + let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.setShowAllSearchResults(true) + await adapter.setSearchText(" swift ") + + #expect(adapter.searchText == " swift ") + #expect(!adapter.showAllSearchResults) + + await waitUntil(timeout: .seconds(2)) { + adapter.searchResults == [TodoListItem(from: todo)] + } + + #expect(fetchSpy.queries.map(\.keyword) == ["swift"]) + #expect(fetchSpy.cursors.map { $0?.documentID } == [nil]) + #expect(!adapter.isLoading) + } + + @Test("setIsSearching false는 검색 상태와 검색 결과 표시 상태를 초기화한다") + func setIsSearching_false는_검색_상태와_검색_결과_표시_상태를_초기화한다() async { + let todo = TodoListItem(from: makeTodoListTodo(id: "todo-search"))! + let adapter = TodoListStoreTestAdapter() + + await adapter.setSearchResults([todo]) + await adapter.setShowAllSearchResults(true) + await adapter.setSearchText("swift") + await adapter.setIsSearching(true) + await adapter.setIsSearching(false) + + #expect(!adapter.isSearching) + #expect(adapter.searchText.isEmpty) + #expect(adapter.searchResults.isEmpty) + #expect(!adapter.showAllSearchResults) + #expect(!adapter.isLoading) + } + + @Test("swipeTodo는 Todo를 숨기고 undoDelete와 finishDeleteToast는 숨김 상태를 되돌리거나 제거한다") + func swipeTodo는_Todo를_숨기고_undoDelete와_finishDeleteToast는_숨김_상태를_되돌리거나_제거한다() async { + let todo = makeTodoListTodo(id: "todo-delete") + let item = TodoListItem(from: todo)! + let deleteSpy = TodoListDeleteTodoUseCaseSpy() + let undoSpy = TodoListUndoDeleteTodoUseCaseSpy() + let adapter = TodoListStoreTestAdapter( + deleteUseCase: deleteSpy, + undoDeleteUseCase: undoSpy + ) + + await adapter.appendTodos([item]) + await adapter.setSearchResults([item]) + await adapter.swipeTodo(item) + + #expect(adapter.todos.first?.isHidden == true) + #expect(adapter.searchResults.first?.isHidden == true) + + await waitUntil { + deleteSpy.todoIds == ["todo-delete"] + } + + await adapter.undoDelete() + + #expect(adapter.todos.first?.isHidden == false) + #expect(adapter.searchResults.first?.isHidden == false) + + await waitUntil { + undoSpy.todoIds == ["todo-delete"] + } + + await adapter.swipeTodo(item) + await adapter.finishDeleteToast("todo-delete") + + #expect(adapter.todos.isEmpty) + #expect(adapter.searchResults.isEmpty) + } + + @Test("tapToggleCompleted와 tapTogglePinned는 조회한 Todo를 갱신해 목록에 반영한다") + func tapToggleCompleted와_tapTogglePinned는_조회한_Todo를_갱신해_목록에_반영한다() async { + let todo = makeTodoListTodo(id: "todo-toggle", isPinned: false, isCompleted: false) + let item = TodoListItem(from: todo)! + let fetchByIdSpy = TodoListFetchTodoByIdUseCaseSpy(todos: [todo]) + let upsertSpy = TodoListUpsertTodoUseCaseSpy() + let trackSpy = TodoListTrackAnalyticsEventUseCaseSpy() + let adapter = TodoListStoreTestAdapter( + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy, + trackAnalyticsEventUseCase: trackSpy + ) + + await adapter.appendTodos([item]) + await adapter.tapToggleCompleted(item) + + await waitUntil { + adapter.todos.first?.isCompleted == true + } + + #expect(upsertSpy.todos.first?.isCompleted == true) + #expect(trackSpy.hasTrackedTodoComplete) + + fetchByIdSpy.todos = [upsertSpy.todos[0]] + await adapter.tapTogglePinned(adapter.todos[0]) + + await waitUntil { + adapter.todos.first?.isPinned == true + } + + #expect(upsertSpy.todos.last?.isPinned == true) + } + + @Test("Todo 조회 실패 시 공통 에러 알림 상태를 표시한다") + func Todo_조회_실패_시_공통_에러_알림_상태를_표시한다() async { + let fetchSpy = TodoListFetchTodosUseCaseSpy() + fetchSpy.error = TodoListTestError.failure + let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.onAppear() + + await waitUntil { + adapter.showAlert + } + + #expect(adapter.showAlert) + #expect(adapter.alertTitle == String(localized: "common_error_title")) + #expect(adapter.alertMessage == String(localized: "common_error_message")) + } +} From e55ef98c51fd68378b7670c3b0b6e26cf69a4e9e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:21:51 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20TodoListFeature=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/List/TodoListFeature+Effects.swift | 140 ++++++++ .../Sources/Home/List/TodoListFeature.swift | 339 ++++++++++++++++++ .../Todo/TodoQuery+Presentation.swift | 50 +++ 3 files changed, 529 insertions(+) create mode 100644 Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift create mode 100644 Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift create mode 100644 Application/DevLogPresentation/Sources/Structure/Todo/TodoQuery+Presentation.swift diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift new file mode 100644 index 00000000..c9aad43a --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift @@ -0,0 +1,140 @@ +// +// TodoListFeature+Effects.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +extension TodoListFeature { + func searchEffect( + _ keyword: String, + category: TodoCategory + ) -> Effect { + .run { [fetchTodosUseCase] send in + do { + let query = TodoQuery(categoryId: category.storageValue, keyword: keyword) + let page = try await fetchTodosUseCase.execute(query, cursor: nil) + try Task.checkCancellation() + await send(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:)))) + await send(.loading(.end(target: .default, mode: .immediate))) + } catch is CancellationError { + return + } catch { + await send(.setAlert(true)) + await send(.loading(.end(target: .default, mode: .immediate))) + } + } + .cancellable(id: CancelID.request, cancelInFlight: true) + } + + func toggleCompletedEffect(_ item: TodoListItem) -> Effect { + .concatenate( + .send(.loading(.begin(target: .default, mode: .delayed))), + .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase] send in + do { + var todo = try await fetchTodoByIdUseCase.execute(item.id) + let now = Date() + todo.isCompleted.toggle() + todo.completedAt = todo.isCompleted ? now : nil + todo.updatedAt = now + try await upsertTodoUseCase.execute(todo) + if todo.isCompleted { + trackAnalyticsEventUseCase?.execute(.todoComplete) + } + guard let todoListItem = TodoListItem(from: todo) else { + await send(.setAlert(true)) + await send(.loading(.end(target: .default, mode: .delayed))) + return + } + await send(.didToggleCompleted(todoListItem)) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.setAlert(true)) + await send(.loading(.end(target: .default, mode: .delayed))) + } + } + ) + } + + func togglePinnedEffect(_ item: TodoListItem) -> Effect { + .concatenate( + .send(.loading(.begin(target: .default, mode: .delayed))), + .run { [fetchTodoByIdUseCase, upsertTodoUseCase] send in + do { + var todo = try await fetchTodoByIdUseCase.execute(item.id) + todo.isPinned.toggle() + todo.updatedAt = Date() + try await upsertTodoUseCase.execute(todo) + guard let todoListItem = TodoListItem(from: todo) else { + await send(.setAlert(true)) + await send(.loading(.end(target: .default, mode: .delayed))) + return + } + await send(.didTogglePinned(todoListItem)) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.setAlert(true)) + await send(.loading(.end(target: .default, mode: .delayed))) + } + } + ) + } + + func swipeTodoEffect(_ todo: TodoListItem, state: inout State) -> Effect { + guard state.todos.contains(where: { $0.id == todo.id }) else { return .none } + state.undoTodoId = todo.id + state.deleteToastTodoId = todo.id + Self.setTodoHidden(&state, todoId: todo.id, isHidden: true) + return deleteEffect(todo) + } + + func deleteEffect(_ item: TodoListItem) -> Effect { + .run { [deleteTodoUseCase] send in + do { + try await deleteTodoUseCase.execute(item.id) + } catch { + await send(.setTodoHidden(item.id, false)) + await send(.setAlert(true)) + } + } + } + + func undoDeleteEffect(_ todoId: String) -> Effect { + .run { [undoDeleteTodoUseCase] send in + do { + try await undoDeleteTodoUseCase.execute(todoId) + } catch { + await send(.setTodoHidden(todoId, true)) + await send(.setAlert(true)) + } + } + } + + static func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = String(localized: "common_error_title") + state.alertMessage = String(localized: "common_error_message") + state.showAlert = isPresented + } + + static func setTodoHidden( + _ state: inout State, + todoId: String, + isHidden: Bool + ) { + if let todoIndex = state.todos.firstIndex(where: { $0.id == todoId }) { + state.todos[todoIndex].isHidden = isHidden + } + + if let searchResultIndex = state.searchResults.firstIndex(where: { $0.id == todoId }) { + state.searchResults[searchResultIndex].isHidden = isHidden + } + } +} diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift new file mode 100644 index 00000000..737b5fa5 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -0,0 +1,339 @@ +// +// TodoListFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +@Reducer +struct TodoListFeature { + @ObservableState + struct State: Equatable { + var category: TodoCategory + var todos: [TodoListItem] = [] + var searchText = "" + var searchResults: [TodoListItem] = [] + var showEditor = false + var showAlert = false + var alertTitle = "" + var alertMessage = "" + var isSearching = false + var showAllSearchResults = false + var query: TodoQuery + var hasMore = false + var loading = LoadingFeature.State() + var undoTodoId: String? + var nextCursor: TodoCursor? + var deleteToastTodoId: String? + let searchResultsLimit = 5 + + init(category: TodoCategory) { + self.category = category + self.query = TodoQuery(categoryId: category.storageValue) + } + + var isLoading: Bool { + loading.isLoading + } + + var appliedFilterCount: Int { + var count = 0 + if query.sortTarget != .createdAt { count += 1 } + if query.sortOrder != .latest { count += 1 } + if query.isPinned != nil { count += 1 } + if query.completionFilter != .all { count += 1 } + return count + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.category == rhs.category && + lhs.todos == rhs.todos && + lhs.searchText == rhs.searchText && + lhs.searchResults == rhs.searchResults && + lhs.showEditor == rhs.showEditor && + lhs.showAlert == rhs.showAlert && + lhs.alertTitle == rhs.alertTitle && + lhs.alertMessage == rhs.alertMessage && + lhs.isSearching == rhs.isSearching && + lhs.showAllSearchResults == rhs.showAllSearchResults && + lhs.query == rhs.query && + lhs.hasMore == rhs.hasMore && + lhs.loading == rhs.loading && + lhs.undoTodoId == rhs.undoTodoId && + lhs.deleteToastTodoId == rhs.deleteToastTodoId && + lhs.nextCursor?.primarySortDate == rhs.nextCursor?.primarySortDate && + lhs.nextCursor?.secondarySortDate == rhs.nextCursor?.secondarySortDate && + lhs.nextCursor?.documentID == rhs.nextCursor?.documentID + } + } + + enum Action { + case refresh + case setAlert(Bool) + case setShowEditor(Bool) + case swipeTodo(TodoListItem) + case setSortTarget(TodoQuery.SortTarget) + case setSortOrder(TodoQuery.SortOrder) + case togglePinnedOnly + case setCompletionFilter(TodoQuery.CompletionFilter) + case resetFilters + case setIsSearching(Bool) + case setShowAllSearchResults(Bool) + case finishDeleteToast(String) + case presentedDeleteToast + case tapToggleCompleted(TodoListItem) + case tapTogglePinned(TodoListItem) + case undoDelete + case onAppear + case loadNextPage + case setSearchText(String) + case applySearchQuery(String) + case fetchSearchResults([TodoListItem]) + case didToggleCompleted(TodoListItem) + case didTogglePinned(TodoListItem) + case setTodoHidden(String, Bool) + case appendTodos([TodoListItem], nextCursor: TodoCursor?) + case resetPagination + case setHasMore(Bool) + case loading(LoadingFeature.Action) + } + + enum CancelID: Hashable { + case debounce + case request + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.todoListFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase + @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase + @Dependency(\.todoListDeleteTodoUseCase) var deleteTodoUseCase + @Dependency(\.todoListUndoDeleteTodoUseCase) var undoDeleteTodoUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + + private let searchDebounceDelay = Duration.seconds(0.4) + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + Reduce { state, action in + reduce(action, state: &state) + } + } +} + +extension DependencyValues { + var todoListFetchTodosUseCase: FetchTodosUseCase { + get { self[TodoListFetchTodosUseCaseKey.self] } + set { self[TodoListFetchTodosUseCaseKey.self] = newValue } + } + + var todoListDeleteTodoUseCase: DeleteTodoUseCase { + get { self[TodoListDeleteTodoUseCaseKey.self] } + set { self[TodoListDeleteTodoUseCaseKey.self] = newValue } + } + + var todoListUndoDeleteTodoUseCase: UndoDeleteTodoUseCase { + get { self[TodoListUndoDeleteTodoUseCaseKey.self] } + set { self[TodoListUndoDeleteTodoUseCaseKey.self] = newValue } + } +} + +private enum TodoListFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +private enum TodoListDeleteTodoUseCaseKey: DependencyKey { + static var liveValue: DeleteTodoUseCase { + preconditionFailure("DeleteTodoUseCase must be provided.") + } + + static var testValue: DeleteTodoUseCase { + liveValue + } +} + +private enum TodoListUndoDeleteTodoUseCaseKey: DependencyKey { + static var liveValue: UndoDeleteTodoUseCase { + preconditionFailure("UndoDeleteTodoUseCase must be provided.") + } + + static var testValue: UndoDeleteTodoUseCase { + liveValue + } +} + +private extension TodoListFeature { + func reduce(_ action: Action, state: inout State) -> Effect { + switch action { + case .refresh, .onAppear: + return fetchEffect(query: state.query, cursor: nil) + case .setAlert(let value): + Self.setAlert(&state, isPresented: value) + case .setShowEditor(let value): + state.showEditor = value + case .swipeTodo(let todo): + return swipeTodoEffect(todo, state: &state) + case .setSortTarget(let target): + state.query.sortTarget = target + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .setSortOrder(let order): + state.query.sortOrder = order + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .togglePinnedOnly: + state.query.isPinned = state.query.isPinned == true ? nil : true + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .setCompletionFilter(let filter): + state.query.completionFilter = filter + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .resetFilters: + state.query = TodoQuery(categoryId: state.category.storageValue) + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .setIsSearching(let value): + state.isSearching = value + if !value { + state.searchText = "" + state.searchResults = [] + state.showAllSearchResults = false + return cancelSearchEffect() + } + case .setShowAllSearchResults(let value): + state.showAllSearchResults = value + 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 .presentedDeleteToast: + state.deleteToastTodoId = 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 .setSearchText(let text): + return setSearchTextEffect(text, state: &state) + 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 + case .loading: + break + } + + return .none + } + + func fetchEffect( + query: TodoQuery, + cursor: TodoCursor?, + resetsPagination: Bool = true + ) -> Effect { + .concatenate( + .send(.loading(.begin(target: .default, mode: .delayed))), + .run { [fetchTodosUseCase] send in + do { + let page = try await fetchTodosUseCase.execute(query, cursor: cursor) + if resetsPagination { + await send(.resetPagination) + } + await send(.appendTodos( + page.items.compactMap(TodoListItem.init(from:)), + nextCursor: page.nextCursor + )) + await send(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil)) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.setAlert(true)) + await send(.loading(.end(target: .default, mode: .delayed))) + } + } + ) + } + + func setSearchTextEffect(_ text: String, state: inout State) -> Effect { + guard state.searchText != text else { return .none } + state.searchText = text + state.showAllSearchResults = false + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + state.searchResults = [] + return cancelSearchEffect() + } else { + return .concatenate( + cancelSearchEffect(), + debounceSearchEffect(trimmed) + ) + } + } + + func applySearchQueryEffect(_ query: String, state: inout State) -> Effect { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + state.searchResults = [] + return cancelSearchEffect() + } else { + return searchEffect(trimmed, category: state.category) + } + } + + func cancelSearchEffect() -> Effect { + .merge( + .cancel(id: CancelID.debounce), + .cancel(id: CancelID.request), + .send(.loading(.end(target: .default, mode: .immediate))) + ) + } + + func debounceSearchEffect(_ keyword: String) -> Effect { + .concatenate( + .send(.loading(.begin(target: .default, mode: .immediate))), + .run { [clock, searchDebounceDelay] send in + try await clock.sleep(for: searchDebounceDelay) + await send(.applySearchQuery(keyword)) + } + .cancellable(id: CancelID.debounce, cancelInFlight: true) + ) + } +} diff --git a/Application/DevLogPresentation/Sources/Structure/Todo/TodoQuery+Presentation.swift b/Application/DevLogPresentation/Sources/Structure/Todo/TodoQuery+Presentation.swift new file mode 100644 index 00000000..e1f7ec6a --- /dev/null +++ b/Application/DevLogPresentation/Sources/Structure/Todo/TodoQuery+Presentation.swift @@ -0,0 +1,50 @@ +// +// TodoQuery+Presentation.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import DevLogCore +import Foundation + +extension TodoQuery.SortTarget { + var title: String { + switch self { + case .createdAt: + return String(localized: "todo_sort_created") + case .completedAt: + return String(localized: "profile_activity_completed") + case .deletedAt: + return String(localized: "profile_activity_deleted") + case .updatedAt: + return String(localized: "todo_sort_updated") + case .dueDate: + return String(localized: "todo_sort_due_date") + } + } +} + +extension TodoQuery.SortOrder { + var title: String { + switch self { + case .latest: + return String(localized: "todo_sort_latest") + case .oldest: + return String(localized: "todo_sort_oldest") + } + } +} + +extension TodoQuery.CompletionFilter { + var title: String { + switch self { + case .all: + return String(localized: "todo_completion_all") + case .incomplete: + return String(localized: "todo_completion_incomplete") + case .completed: + return String(localized: "todo_completion_completed") + } + } +} From f70926ddff7fed7a8147b2b9d7bdecddaf3b34bf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:42:23 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20TodoListView=20Store=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/List/TodoListView.swift | 141 ++--- .../Sources/Home/List/TodoListViewModel.swift | 512 ------------------ .../Sources/Main/MainView.swift | 2 +- .../WindowGroup/TodoWindowCoordinator.swift | 39 +- .../Home/TodoListFeatureTestDoubles.swift | 114 ++-- 5 files changed, 174 insertions(+), 634 deletions(-) delete mode 100644 Application/DevLogPresentation/Sources/Home/List/TodoListViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index f5759749..8b44ca2e 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -19,19 +19,19 @@ struct TodoListView: View { @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @State private var headerOffset: CGFloat = .zero @State private var isScrollTrackingEnabled = false - @State var viewModel: TodoListViewModel + @State var store: StoreOf var body: some View { Group { if #available(iOS 18, *) { - if viewModel.state.isSearching { + if store.state.isSearching { todoSearchContent } else { todoListContent } } else { Group { - if viewModel.state.isSearching { + if store.state.isSearching { searchResultsContent } else { todoListContent @@ -39,40 +39,40 @@ struct TodoListView: View { } .searchable( text: Binding( - get: { viewModel.state.searchText }, - set: { viewModel.send(.setSearchText($0)) } + get: { store.state.searchText }, + set: { store.send(.setSearchText($0)) } ), isPresented: Binding( - get: { viewModel.state.isSearching }, - set: { viewModel.send(.setIsSearching($0)) } + get: { store.state.isSearching }, + set: { store.send(.setIsSearching($0)) } ), placement: .navigationBarDrawer(displayMode: .always), prompt: Text( String.localizedStringWithFormat( String(localized: "todo_list_search_prompt_format"), - TodoCategoryItem(from: viewModel.category).localizedName + TodoCategoryItem(from: store.category).localizedName ) ) ) } } .alert( - viewModel.state.alertTitle, + store.state.alertTitle, isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } + get: { store.state.showAlert }, + set: { store.send(.setAlert($0)) } )) { Button(String(localized: "common_close"), role: .cancel) { } } message: { - Text(viewModel.state.alertMessage) + Text(store.state.alertMessage) } - .navigationTitle(TodoCategoryItem(from: viewModel.category).localizedName) + .navigationTitle(TodoCategoryItem(from: store.category).localizedName) .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showEditor }, - set: { viewModel.send(.setShowEditor($0)) } + get: { store.state.showEditor }, + set: { store.send(.setShowEditor($0)) } )) { TodoEditorView( - store: Store(initialState: TodoEditorFeature.State(category: viewModel.category)) { + store: Store(initialState: TodoEditorFeature.State(category: store.category)) { TodoEditorFeature() } withDependencies: { $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) @@ -81,8 +81,8 @@ struct TodoListView: View { $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) }, onCreateSuccess: { - viewModel.send(.setShowEditor(false)) - viewModel.send(.refresh) + store.send(.setShowEditor(false)) + store.send(.refresh) } ) } @@ -100,7 +100,7 @@ struct TodoListView: View { if #available(iOS 18, *) { ToolbarItem(placement: .topBarTrailing) { Button { - viewModel.send(.setIsSearching(true)) + store.send(.setIsSearching(true)) } label: { Image(systemName: "magnifyingglass") } @@ -109,17 +109,22 @@ struct TodoListView: View { } .background(NavigationBarConfigurator()) .background(Color(.systemGroupedBackground)) - .task { viewModel.send(.onAppear) } + .task { store.send(.onAppear) } + .onChange(of: store.deleteToastTodoId) { _, todoId in + guard let todoId else { return } + presentDeleteTodoToast(todoId) + store.send(.presentedDeleteToast) + } } @ViewBuilder private var todoListContent: some View { - let visibleTodos = viewModel.state.todos.filter { !$0.isHidden } + let visibleTodos = store.state.todos.filter { !$0.isHidden } ZStack { List { Group { - if visibleTodos.isEmpty, !viewModel.state.isLoading { + if visibleTodos.isEmpty, !store.state.isLoading { HStack { Spacer() Text(String(localized: "todo_list_empty")) @@ -146,19 +151,19 @@ struct TodoListView: View { } .onAppear { let lastID = visibleTodos.last?.id - if todo.id == lastID, viewModel.state.hasMore { - viewModel.send(.loadNextPage) + if todo.id == lastID, store.state.hasMore { + store.send(.loadNextPage) } } .swipeActions(edge: .leading) { Button(action: { - viewModel.send(.tapTogglePinned(todo)) + store.send(.tapTogglePinned(todo)) }) { Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") } .tint(Color.orange) Button { - viewModel.send(.tapToggleCompleted(todo)) + store.send(.tapToggleCompleted(todo)) } label: { Image(systemName: todo.isCompleted ? "arrow.uturn.backward" : "checkmark") } @@ -166,7 +171,7 @@ struct TodoListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive, action: { - viewModel.send(.swipeTodo(todo)) + store.send(.swipeTodo(todo)) }) { Image(systemName: "trash") } @@ -199,10 +204,10 @@ struct TodoListView: View { } .offset(y: headerOffset) } - .refreshable { viewModel.send(.refresh) } - .scrollDisabled(visibleTodos.isEmpty || viewModel.state.isLoading) + .refreshable { store.send(.refresh) } + .scrollDisabled(visibleTodos.isEmpty || store.state.isLoading) - if viewModel.state.isLoading { + if store.state.isLoading { LoadingView() } } @@ -213,18 +218,18 @@ struct TodoListView: View { searchResultsContent .searchable( text: Binding( - get: { viewModel.state.searchText }, - set: { viewModel.send(.setSearchText($0)) } + get: { store.state.searchText }, + set: { store.send(.setSearchText($0)) } ), isPresented: Binding( - get: { viewModel.state.isSearching }, - set: { viewModel.send(.setIsSearching($0)) } + get: { store.state.isSearching }, + set: { store.send(.setIsSearching($0)) } ), placement: .navigationBarDrawer(displayMode: .always), prompt: Text( String.localizedStringWithFormat( String(localized: "todo_list_search_prompt_format"), - TodoCategoryItem(from: viewModel.category).localizedName + TodoCategoryItem(from: store.category).localizedName ) ) ) @@ -234,31 +239,45 @@ struct TodoListView: View { if isiOSAppOnMac { openWindow( id: TodoEditorWindowValue.sceneId, - value: TodoEditorWindowValue(todoCategory: viewModel.category, source: .list) + value: TodoEditorWindowValue(todoCategory: store.category, source: .list) ) } else { - viewModel.send(.setShowEditor(true)) + store.send(.setShowEditor(true)) } } + private func presentDeleteTodoToast(_ todoId: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + action: { + store.send(.undoDelete) + }, + onDismiss: { + store.send(.finishDeleteToast(todoId)) + } + ) + } + @ViewBuilder private var searchResultsContent: some View { - let searchResults = viewModel.state.searchResults.filter { !$0.isHidden } - let limit = viewModel.searchResultsLimit - let displayedTodos = viewModel.state.showAllSearchResults + let searchResults = store.state.searchResults.filter { !$0.isHidden } + let limit = store.searchResultsLimit + let displayedTodos = store.state.showAllSearchResults ? searchResults : Array(searchResults.prefix(limit)) - if viewModel.state.searchText.isEmpty { + if store.state.searchText.isEmpty { Text( String.localizedStringWithFormat( String(localized: "todo_list_search_instruction_format"), - TodoCategoryItem(from: viewModel.category).localizedName + TodoCategoryItem(from: store.category).localizedName ) ) .foregroundStyle(Color.gray) .frame(maxWidth: .infinity) - } else if viewModel.state.isLoading { + } else if store.state.isLoading { LoadingView() } else if searchResults.isEmpty { Spacer() @@ -281,9 +300,9 @@ struct TodoListView: View { } .padding(.horizontal, 16) - if !viewModel.state.showAllSearchResults, limit < searchResults.count { + if !store.state.showAllSearchResults, limit < searchResults.count { Button(String(localized: "todo_list_show_more")) { - viewModel.send(.setShowAllSearchResults(true)) + store.send(.setShowAllSearchResults(true)) } .font(.subheadline) .foregroundStyle(Color.gray) @@ -298,16 +317,16 @@ struct TodoListView: View { private var headerView: some View { ScrollView(.horizontal) { HStack(spacing: 8) { - if 0 < viewModel.appliedFilterCount { + if 0 < store.appliedFilterCount { Menu { Text( String.localizedStringWithFormat( String(localized: "todo_list_filters_applied_format"), - Int64(viewModel.appliedFilterCount) + Int64(store.appliedFilterCount) ) ) Button(role: .destructive) { - viewModel.send(.resetFilters) + store.send(.resetFilters) } label: { Text(String(localized: "todo_list_clear_filters")) } @@ -340,8 +359,8 @@ struct TodoListView: View { private var sortMenu: some View { Menu { Picker(selection: Binding( - get: { viewModel.state.query.sortTarget }, - set: { viewModel.send(.setSortTarget($0)) } + get: { store.state.query.sortTarget }, + set: { store.send(.setSortTarget($0)) } )) { ForEach([TodoQuery.SortTarget.createdAt, .updatedAt], id: \.self) { option in Text(option.title).tag(option) @@ -350,8 +369,8 @@ struct TodoListView: View { Text(String(localized: "todo_list_sort_by")) } Picker(selection: Binding( - get: { viewModel.state.query.sortOrder }, - set: { viewModel.send(.setSortOrder($0)) } + get: { store.state.query.sortOrder }, + set: { store.send(.setSortOrder($0)) } )) { ForEach([TodoQuery.SortOrder.latest, .oldest], id: \.self) { option in Text(option.title).tag(option) @@ -360,13 +379,13 @@ struct TodoListView: View { Text(String(localized: "todo_list_sort_order")) } } label: { - let condition = viewModel.state.query.sortTarget == .createdAt && viewModel.state.query.sortOrder == .latest + let condition = store.state.query.sortTarget == .createdAt && store.state.query.sortOrder == .latest HStack { Text( String.localizedStringWithFormat( String(localized: "todo_list_sort_format"), - viewModel.state.query.sortTarget.title, - viewModel.state.query.sortOrder.title + store.state.query.sortTarget.title, + store.state.query.sortOrder.title ) ) Image(systemName: "chevron.down") @@ -379,15 +398,15 @@ struct TodoListView: View { private var filterMenu: some View { Menu { Toggle(isOn: Binding( - get: { viewModel.state.query.isPinned == true }, - set: { _ in viewModel.send(.togglePinnedOnly) } + get: { store.state.query.isPinned == true }, + set: { _ in store.send(.togglePinnedOnly) } )) { Text(String(localized: "todo_pinned")) } Picker(selection: Binding( - get: { viewModel.state.query.completionFilter }, - set: { viewModel.send(.setCompletionFilter($0)) } + get: { store.state.query.completionFilter }, + set: { store.send(.setCompletionFilter($0)) } )) { ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in Text(option.title).tag(option) @@ -396,7 +415,7 @@ struct TodoListView: View { Text(String(localized: "todo_list_completion_status")) } } label: { - let condition = viewModel.state.query.isPinned == true || viewModel.state.query.completionFilter != .all + let condition = store.state.query.isPinned == true || store.state.query.completionFilter != .all HStack { Text(String(localized: "todo_list_filter_options")) Image(systemName: "chevron.down") @@ -412,7 +431,7 @@ struct TodoListView: View { let textColor: Color = isDark ? blue : .white let backgroundColor: Color = isDark ? .white : blue - return Text("\(viewModel.appliedFilterCount)") + return Text("\(store.appliedFilterCount)") .font(.caption2.weight(.bold)) .foregroundColor(textColor) .lineLimit(1) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListViewModel.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListViewModel.swift deleted file mode 100644 index 0ff89534..00000000 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListViewModel.swift +++ /dev/null @@ -1,512 +0,0 @@ -// -// TodoListViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/22/25. -// - -import Foundation -import DevLogCore -import DevLogDomain - -@Observable -final class TodoListViewModel: StorePattern { - struct State: Equatable { - var todos: [TodoListItem] = [] - var searchText: String = "" - var searchResults: [TodoListItem] = [] - var showEditor: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var isSearching: Bool = false - var showAllSearchResults: Bool = false - var query: TodoQuery - var isLoading: Bool = false - var hasMore: Bool = false - } - - enum Action { - // User - case refresh - case setAlert(Bool) - case setShowEditor(Bool) - case swipeTodo(TodoListItem) - case setSortTarget(TodoQuery.SortTarget) - case setSortOrder(TodoQuery.SortOrder) - case togglePinnedOnly - case setCompletionFilter(TodoQuery.CompletionFilter) - case resetFilters - case setIsSearching(Bool) - case setShowAllSearchResults(Bool) - case finishDeleteToast(String) - case tapToggleCompleted(TodoListItem) - case tapTogglePinned(TodoListItem) - case undoDelete - - // View - case onAppear - case loadNextPage - case setSearchText(String) - - // Run - case applySearchQuery(String) - case fetchSearchResults([TodoListItem]) - case didToggleCompleted(TodoListItem) - case didTogglePinned(TodoListItem) - case setTodoHidden(String, Bool) - case setLoading(Bool) - case appendTodos([TodoListItem], nextCursor: TodoCursor?) - case resetPagination - case setHasMore(Bool) - } - - enum SideEffect { - case cancelSearch - case debounceSearch(String) - case fetch - case loadNextPage - case search(String) - case delete(TodoListItem) - case undoDelete(String) - case toggleCompleted(TodoListItem) - case togglePinned(TodoListItem) - } - - private enum SearchTaskKind: Hashable { - case debounce - case request - } - - let category: TodoCategory - private(set) var state: State - private let fetchTodosUseCase: FetchTodosUseCase - private let fetchTodoByIdUseCase: FetchTodoByIdUseCase - private let upsertTodoUseCase: UpsertTodoUseCase - private let deleteTodoUseCase: DeleteTodoUseCase - private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - private let loadingState = LoadingState() - private var undoTodoId: String? - private var nextCursor: TodoCursor? - private var searchTasks: [SearchTaskKind: Task] = [:] - private let searchDebounceDelay: Double = 0.4 - - init( - fetchTodosUseCase: FetchTodosUseCase, - fetchTodoByIdUseCase: FetchTodoByIdUseCase, - upsertTodoUseCase: UpsertTodoUseCase, - deleteTodoUseCase: DeleteTodoUseCase, - undoDeleteTodoUseCase: UndoDeleteTodoUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, - category: TodoCategory - ) { - self.fetchTodosUseCase = fetchTodosUseCase - self.fetchTodoByIdUseCase = fetchTodoByIdUseCase - self.upsertTodoUseCase = upsertTodoUseCase - self.deleteTodoUseCase = deleteTodoUseCase - self.undoDeleteTodoUseCase = undoDeleteTodoUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.category = category - self.state = State( - query: TodoQuery(categoryId: category.storageValue) - ) - } - - let searchResultsLimit = 5 - - var appliedFilterCount: Int { - var count = 0 - if state.query.sortTarget != .createdAt { count += 1 } - if state.query.sortOrder != .latest { count += 1 } - if state.query.isPinned != nil { count += 1 } - if state.query.completionFilter != .all { count += 1 } - return count - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .refresh, .setAlert, .setShowEditor, .swipeTodo, .setSortTarget, .setSortOrder, - .togglePinnedOnly, .setCompletionFilter, .resetFilters, .setIsSearching, - .setShowAllSearchResults, .finishDeleteToast, .tapToggleCompleted, .tapTogglePinned, .undoDelete: - effects = reduceByUser(action, state: &state) - - case .onAppear, .loadNextPage, .setSearchText: - effects = reduceByView(action, state: &state) - - case .applySearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, - .setTodoHidden, .setLoading, .appendTodos, .resetPagination, .setHasMore: - effects = reduceByRun(action, state: &state) - } - - if self.state != state { self.state = state } - return effects - } - - // swiftlint:disable function_body_length - func run(_ effect: SideEffect) { - switch effect { - case .cancelSearch: - cancelSearch() - case .debounceSearch(let keyword): - beginLoading(.immediate) - scheduleDebouncedSearch(keyword) - case .fetch: - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - let page = try await fetchTodosUseCase.execute(state.query, cursor: nil) - send(.resetPagination) - send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) - let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil - send(.setHasMore(hasMore)) - } catch { - send(.setAlert(true)) - } - } - case .loadNextPage: - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - let page = try await fetchTodosUseCase.execute(state.query, cursor: nextCursor) - send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) - let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil - send(.setHasMore(hasMore)) - } catch { - send(.setAlert(true)) - } - } - case .search(let keyword): - searchTasks[.request]?.cancel() - let requestTask = Task { [weak self] in - guard let self else { return } - do { - defer { - self.searchTasks[.request] = nil - if !Task.isCancelled { - self.endLoading(.immediate) - } - } - let query = TodoQuery(categoryId: category.storageValue, keyword: keyword) - let page = try await fetchTodosUseCase.execute(query, cursor: nil) - if Task.isCancelled { return } - send(.fetchSearchResults(page.items.compactMap { TodoListItem(from: $0) })) - } catch { - if error is CancellationError { return } - send(.setAlert(true)) - } - } - searchTasks[.request] = requestTask - case .toggleCompleted(let item): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - var todo = try await fetchTodoByIdUseCase.execute(item.id) - let now = Date() - todo.isCompleted.toggle() - todo.completedAt = todo.isCompleted ? now : nil - todo.updatedAt = now - try await upsertTodoUseCase.execute(todo) - if todo.isCompleted { - trackAnalyticsEventUseCase.execute(.todoComplete) - } - guard let todoListItem = TodoListItem(from: todo) else { - send(.setAlert(true)) - return - } - send(.didToggleCompleted(todoListItem)) - } catch { - send(.setAlert(true)) - } - } - case .togglePinned(let item): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - var todo = try await fetchTodoByIdUseCase.execute(item.id) - todo.isPinned.toggle() - todo.updatedAt = Date() - try await upsertTodoUseCase.execute(todo) - guard let todoListItem = TodoListItem(from: todo) else { - send(.setAlert(true)) - return - } - send(.didTogglePinned(todoListItem)) - } catch { - send(.setAlert(true)) - } - } - case .delete(let item): - Task { - do { - try await deleteTodoUseCase.execute(item.id) - } catch { - send(.setTodoHidden(item.id, false)) - send(.setAlert(true)) - } - } - case .undoDelete(let todoId): - Task { - do { - try await undoDeleteTodoUseCase.execute(todoId) - } catch { - send(.setTodoHidden(todoId, true)) - send(.setAlert(true)) - } - } - } - } - // swiftlint:enable function_body_length -} - -// MARK: - Reduce Methods -private extension TodoListViewModel { - func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .refresh: - return [.fetch] - case .setAlert(let value): - setAlert(&state, isPresented: value) - case .setShowEditor(let value): - state.showEditor = value - case .swipeTodo(let todo): - if state.todos.contains(where: { $0.id == todo.id }) { - self.undoTodoId = todo.id - setTodoHidden(&state, todoId: todo.id, isHidden: true) - presentDeleteTodoToast(todo.id) - return [.delete(todo)] - } - case .setSortTarget(let target): - state.query.sortTarget = target - self.nextCursor = nil - return [.fetch] - case .setSortOrder(let order): - state.query.sortOrder = order - self.nextCursor = nil - return [.fetch] - case .togglePinnedOnly: - state.query.isPinned = state.query.isPinned == true ? nil : true - self.nextCursor = nil - return [.fetch] - case .setCompletionFilter(let filter): - state.query.completionFilter = filter - self.nextCursor = nil - return [.fetch] - case .resetFilters: - state.query = TodoQuery(categoryId: category.storageValue) - self.nextCursor = nil - return [.fetch] - case .setIsSearching(let value): - state.isSearching = value - if !value { - state.searchText = "" - state.searchResults = [] - state.showAllSearchResults = false - return [.cancelSearch] - } - case .setShowAllSearchResults(let value): - state.showAllSearchResults = value - case .tapToggleCompleted(let todo): - return [.toggleCompleted(todo)] - case .tapTogglePinned(let todo): - return [.togglePinned(todo)] - case .undoDelete: - guard let undoTodoId else { return [] } - setTodoHidden(&state, todoId: undoTodoId, isHidden: false) - self.undoTodoId = nil - return [.undoDelete(undoTodoId)] - case .finishDeleteToast(let todoId): - state.todos.removeAll { $0.id == todoId && $0.isHidden } - state.searchResults.removeAll { $0.id == todoId && $0.isHidden } - if self.undoTodoId == todoId { - self.undoTodoId = nil - } - default: - break - } - return [] - } - - func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .onAppear: - return [.fetch] - case .loadNextPage: - guard state.hasMore, !state.isLoading else { return [] } - return [.loadNextPage] - case .setSearchText(let text): - guard state.searchText != text else { return [] } - state.searchText = text - state.showAllSearchResults = false - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.searchResults = [] - return [.cancelSearch] - } else { - return [.cancelSearch, .debounceSearch(trimmed)] - } - default: - break - } - return [] - } - - func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .applySearchQuery(let query): - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.searchResults = [] - return [.cancelSearch] - } else { - return [.search(trimmed)] - } - case .fetchSearchResults(let items): - state.searchResults = items - case .didToggleCompleted(let todo): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - state.todos[index] = todo - } - case .didTogglePinned(let todo): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - state.todos[index] = todo - } - case .setTodoHidden(let todoId, let isHidden): - setTodoHidden(&state, todoId: todoId, isHidden: isHidden) - case .setLoading(let value): - state.isLoading = value - case .appendTodos(let todos, let nextCursor): - state.todos.append(contentsOf: todos) - self.nextCursor = nextCursor - case .resetPagination: - state.todos = [] - self.nextCursor = nil - state.hasMore = false - case .setHasMore(let value): - state.hasMore = value - default: - break - } - return [] - } -} - -// MARK: - Helper Methods -private extension TodoListViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - func presentDeleteTodoToast(_ todoId: String) { - ToastPresenter.present( - message: String(localized: "common_undo"), - systemImage: "arrow.uturn.left", - duration: 5, - action: { [weak self] in - self?.send(.undoDelete) - }, - onDismiss: { [weak self] in - self?.send(.finishDeleteToast(todoId)) - } - ) - } - - func setTodoHidden( - _ state: inout State, - todoId: String, - isHidden: Bool - ) { - if let todoIndex = state.todos.firstIndex(where: { $0.id == todoId }) { - state.todos[todoIndex].isHidden = isHidden - } - - if let searchResultIndex = state.searchResults.firstIndex(where: { $0.id == todoId }) { - state.searchResults[searchResultIndex].isHidden = isHidden - } - } - - func scheduleDebouncedSearch(_ query: String) { - searchTasks[.debounce]?.cancel() - let debounceTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: .seconds(searchDebounceDelay)) - if Task.isCancelled { return } - await MainActor.run { - self.searchTasks[.debounce] = nil - self.send(.applySearchQuery(query)) - } - } - searchTasks[.debounce] = debounceTask - } - - func cancelSearch() { - searchTasks.values.forEach { $0.cancel() } - searchTasks = [:] - endLoading(.immediate) - } - - private func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - private func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} - -extension TodoQuery.SortTarget { - var title: String { - switch self { - case .createdAt: - return String(localized: "todo_sort_created") - case .completedAt: - return String(localized: "profile_activity_completed") - case .deletedAt: - return String(localized: "profile_activity_deleted") - case .updatedAt: - return String(localized: "todo_sort_updated") - case .dueDate: - return String(localized: "todo_sort_due_date") - } - } -} - -extension TodoQuery.SortOrder { - var title: String { - switch self { - case .latest: - return String(localized: "todo_sort_latest") - case .oldest: - return String(localized: "todo_sort_oldest") - } - } -} - -extension TodoQuery.CompletionFilter { - var title: String { - switch self { - case .all: - return String(localized: "todo_completion_all") - case .incomplete: - return String(localized: "todo_completion_incomplete") - case .completed: - return String(localized: "todo_completion_completed") - } - } -} diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index dd09ee42..4f78e005 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -228,7 +228,7 @@ struct MainView: View { switch homeRoute { case .category(let item): TodoListView( - viewModel: todoWindowCoordinator.makeListViewModel(category: item.todoCategory) + store: todoWindowCoordinator.makeListStore(category: item.todoCategory) ) .id(item.id) case .todo(let item): diff --git a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift index 22d1bb0a..22576937 100644 --- a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift +++ b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift @@ -16,7 +16,7 @@ import DevLogDomain final class TodoWindowCoordinator { private let container: DIContainer @ObservationIgnored - private var listViewModel: TodoListViewModel? + private var listStore: StoreOf? @ObservationIgnored private var detailStore: StoreOf? @ObservationIgnored @@ -35,23 +35,24 @@ final class TodoWindowCoordinator { } } - func makeListViewModel(category: TodoCategory) -> TodoListViewModel { - if let listViewModel, - listViewModel.category == category { - return listViewModel + func makeListStore(category: TodoCategory) -> StoreOf { + if let listStore, + listStore.category == category { + return listStore } - let listViewModel = TodoListViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), - undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - category: category - ) - self.listViewModel = listViewModel - return listViewModel + let listStore = Store(initialState: TodoListFeature.State(category: category)) { + TodoListFeature() + } withDependencies: { + $0.todoListFetchTodosUseCase = self.container.resolve(FetchTodosUseCase.self) + $0.fetchTodoByIdUseCase = self.container.resolve(FetchTodoByIdUseCase.self) + $0.upsertTodoUseCase = self.container.resolve(UpsertTodoUseCase.self) + $0.todoListDeleteTodoUseCase = self.container.resolve(DeleteTodoUseCase.self) + $0.todoListUndoDeleteTodoUseCase = self.container.resolve(UndoDeleteTodoUseCase.self) + $0.trackAnalyticsEventUseCase = self.container.resolve(TrackAnalyticsEventUseCase.self) + } + self.listStore = listStore + return listStore } func makeDetailStore( @@ -81,9 +82,9 @@ final class TodoWindowCoordinator { private func handleTodoEditorSubmit(_ submit: TodoEditorWindowSubmit) { switch submit { case .create(let value): - if let listViewModel, - value.matchesCreate(category: listViewModel.category, source: .list) { - listViewModel.send(.refresh) + if let listStore, + value.matchesCreate(category: listStore.category, source: .list) { + listStore.send(.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 42712aa3..0ddb8e09 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -6,26 +6,27 @@ // import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @testable import DevLogPresentation @MainActor final class TodoListStoreTestAdapter { - private let viewModel: TodoListViewModel - - var todos: [TodoListItem] { viewModel.state.todos } - var searchText: String { viewModel.state.searchText } - var searchResults: [TodoListItem] { viewModel.state.searchResults } - var isSearching: Bool { viewModel.state.isSearching } - var showAllSearchResults: Bool { viewModel.state.showAllSearchResults } - var query: TodoQuery { viewModel.state.query } - var isLoading: Bool { viewModel.state.isLoading } - var hasMore: Bool { viewModel.state.hasMore } - var showAlert: Bool { viewModel.state.showAlert } - var alertTitle: String { viewModel.state.alertTitle } - var alertMessage: String { viewModel.state.alertMessage } - var appliedFilterCount: Int { viewModel.appliedFilterCount } + private let store: TestStoreOf + + var todos: [TodoListItem] { store.state.todos } + var searchText: String { store.state.searchText } + var searchResults: [TodoListItem] { store.state.searchResults } + var isSearching: Bool { store.state.isSearching } + var showAllSearchResults: Bool { store.state.showAllSearchResults } + var query: TodoQuery { store.state.query } + var isLoading: Bool { store.state.isLoading } + var hasMore: Bool { store.state.hasMore } + var showAlert: Bool { store.state.showAlert } + var alertTitle: String { store.state.alertTitle } + var alertMessage: String { store.state.alertMessage } + var appliedFilterCount: Int { store.state.appliedFilterCount } init( fetchUseCase: FetchTodosUseCase = TodoListFetchTodosUseCaseSpy(), @@ -34,85 +35,116 @@ final class TodoListStoreTestAdapter { deleteUseCase: DeleteTodoUseCase = TodoListDeleteTodoUseCaseSpy(), undoDeleteUseCase: UndoDeleteTodoUseCase = TodoListUndoDeleteTodoUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodoListTrackAnalyticsEventUseCaseSpy(), - category: TodoCategory = .system(.feature) + category: TodoCategory = .system(.feature), + configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { - viewModel = TodoListViewModel( - fetchTodosUseCase: fetchUseCase, - fetchTodoByIdUseCase: fetchTodoByIdUseCase, - upsertTodoUseCase: upsertUseCase, - deleteTodoUseCase: deleteUseCase, - undoDeleteTodoUseCase: undoDeleteUseCase, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCase, - category: category - ) + store = TestStore(initialState: TodoListFeature.State(category: category)) { + TodoListFeature() + } withDependencies: { + $0.todoListFetchTodosUseCase = fetchUseCase + $0.fetchTodoByIdUseCase = fetchTodoByIdUseCase + $0.upsertTodoUseCase = upsertUseCase + $0.todoListDeleteTodoUseCase = deleteUseCase + $0.todoListUndoDeleteTodoUseCase = undoDeleteUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) } func onAppear() async { - viewModel.send(.onAppear) + await store.send(.onAppear) + await drainReceivedActions() } func loadNextPage() async { - viewModel.send(.loadNextPage) + await store.send(.loadNextPage) + await drainReceivedActions() } func setSortTarget(_ target: TodoQuery.SortTarget) async { - viewModel.send(.setSortTarget(target)) + await store.send(.setSortTarget(target)) + await drainReceivedActions() } func setSortOrder(_ order: TodoQuery.SortOrder) async { - viewModel.send(.setSortOrder(order)) + await store.send(.setSortOrder(order)) + await drainReceivedActions() } func togglePinnedOnly() async { - viewModel.send(.togglePinnedOnly) + await store.send(.togglePinnedOnly) + await drainReceivedActions() } func setCompletionFilter(_ filter: TodoQuery.CompletionFilter) async { - viewModel.send(.setCompletionFilter(filter)) + await store.send(.setCompletionFilter(filter)) + await drainReceivedActions() } func resetFilters() async { - viewModel.send(.resetFilters) + await store.send(.resetFilters) + await drainReceivedActions() } func setSearchText(_ text: String) async { - viewModel.send(.setSearchText(text)) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + await store.send(.setSearchText(text)) + await drainReceivedActions() + + if !trimmed.isEmpty { + try? await Task.sleep(for: .milliseconds(450)) + await drainReceivedActions() + } } func setSearchResults(_ results: [TodoListItem]) async { - viewModel.send(.fetchSearchResults(results)) + await store.send(.fetchSearchResults(results)) } func setIsSearching(_ value: Bool) async { - viewModel.send(.setIsSearching(value)) + await store.send(.setIsSearching(value)) + await drainReceivedActions() } func setShowAllSearchResults(_ value: Bool) async { - viewModel.send(.setShowAllSearchResults(value)) + await store.send(.setShowAllSearchResults(value)) } func appendTodos(_ todos: [TodoListItem]) async { - viewModel.send(.appendTodos(todos, nextCursor: nil)) + await store.send(.appendTodos(todos, nextCursor: nil)) } func swipeTodo(_ todo: TodoListItem) async { - viewModel.send(.swipeTodo(todo)) + await store.send(.swipeTodo(todo)) + await store.send(.presentedDeleteToast) + await drainReceivedActions() } func undoDelete() async { - viewModel.send(.undoDelete) + await store.send(.undoDelete) + await drainReceivedActions() } func finishDeleteToast(_ todoId: String) async { - viewModel.send(.finishDeleteToast(todoId)) + await store.send(.finishDeleteToast(todoId)) } func tapToggleCompleted(_ todo: TodoListItem) async { - viewModel.send(.tapToggleCompleted(todo)) + await store.send(.tapToggleCompleted(todo)) + await drainReceivedActions() } func tapTogglePinned(_ todo: TodoListItem) async { - viewModel.send(.tapTogglePinned(todo)) + await store.send(.tapTogglePinned(todo)) + await drainReceivedActions() + } + + private func drainReceivedActions() async { + for _ in 0..<8 { + await store.skipReceivedActions(strict: false) + } } } From fe03aa66be924d463ea1015f03781a6c0f82dc96 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:57:59 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20BindingAction=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 --- .../DevLogCore/Sources/TodoQuery.swift | 10 +-- .../Sources/Home/List/TodoListFeature.swift | 69 +++++++------------ .../Sources/Home/List/TodoListView.swift | 59 ++++------------ .../Home/TodoListFeatureTestDoubles.swift | 14 ++-- 4 files changed, 53 insertions(+), 99 deletions(-) diff --git a/Application/DevLogCore/Sources/TodoQuery.swift b/Application/DevLogCore/Sources/TodoQuery.swift index 8b95a309..f3298371 100644 --- a/Application/DevLogCore/Sources/TodoQuery.swift +++ b/Application/DevLogCore/Sources/TodoQuery.swift @@ -7,8 +7,8 @@ import Foundation -public struct TodoQuery: Equatable { - public enum SortTarget: Equatable, Hashable { +public struct TodoQuery: Equatable, Sendable { + public enum SortTarget: Equatable, Hashable, Sendable { case createdAt case completedAt case deletedAt @@ -16,18 +16,18 @@ public struct TodoQuery: Equatable { case dueDate } - public enum SortOrder: Equatable, Hashable { + public enum SortOrder: Equatable, Hashable, Sendable { case latest case oldest } - public enum CompletionFilter: Equatable, Hashable { + public enum CompletionFilter: Equatable, Hashable, Sendable { case all case incomplete case completed } - public enum DueDateFilter: Equatable, Hashable { + public enum DueDateFilter: Equatable, Hashable, Sendable { case all case withDueDate case withoutDueDate diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index 737b5fa5..bd7485bf 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -50,6 +50,11 @@ struct TodoListFeature { return count } + var isPinnedOnly: Bool { + get { query.isPinned == true } + set { query.isPinned = newValue ? true : nil } + } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.category == rhs.category && lhs.todos == rhs.todos && @@ -72,18 +77,12 @@ struct TodoListFeature { } } - enum Action { + enum Action: BindableAction { + case binding(BindingAction) case refresh case setAlert(Bool) - case setShowEditor(Bool) case swipeTodo(TodoListItem) - case setSortTarget(TodoQuery.SortTarget) - case setSortOrder(TodoQuery.SortOrder) - case togglePinnedOnly - case setCompletionFilter(TodoQuery.CompletionFilter) case resetFilters - case setIsSearching(Bool) - case setShowAllSearchResults(Bool) case finishDeleteToast(String) case presentedDeleteToast case tapToggleCompleted(TodoListItem) @@ -91,7 +90,6 @@ struct TodoListFeature { case undoDelete case onAppear case loadNextPage - case setSearchText(String) case applySearchQuery(String) case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) @@ -122,6 +120,7 @@ struct TodoListFeature { Scope(state: \.loading, action: \.loading) { LoadingFeature() } + BindingReducer() Reduce { state, action in reduce(action, state: &state) } @@ -178,44 +177,32 @@ private enum TodoListUndoDeleteTodoUseCaseKey: DependencyKey { private extension TodoListFeature { func reduce(_ action: Action, state: inout State) -> Effect { switch action { + 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(\.showAlert): + Self.setAlert(&state, isPresented: state.showAlert) + case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.isPinnedOnly), + .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 .setAlert(let value): Self.setAlert(&state, isPresented: value) - case .setShowEditor(let value): - state.showEditor = value case .swipeTodo(let todo): return swipeTodoEffect(todo, state: &state) - case .setSortTarget(let target): - state.query.sortTarget = target - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .setSortOrder(let order): - state.query.sortOrder = order - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .togglePinnedOnly: - state.query.isPinned = state.query.isPinned == true ? nil : true - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .setCompletionFilter(let filter): - state.query.completionFilter = filter - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) case .resetFilters: state.query = TodoQuery(categoryId: state.category.storageValue) state.nextCursor = nil return fetchEffect(query: state.query, cursor: nil) - case .setIsSearching(let value): - state.isSearching = value - if !value { - state.searchText = "" - state.searchResults = [] - state.showAllSearchResults = false - return cancelSearchEffect() - } - case .setShowAllSearchResults(let value): - state.showAllSearchResults = value case .finishDeleteToast(let todoId): state.todos.removeAll { $0.id == todoId && $0.isHidden } state.searchResults.removeAll { $0.id == todoId && $0.isHidden } @@ -236,8 +223,6 @@ private extension TodoListFeature { case .loadNextPage: guard state.hasMore, !state.isLoading else { return .none } return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) - case .setSearchText(let text): - return setSearchTextEffect(text, state: &state) case .applySearchQuery(let query): return applySearchQueryEffect(query, state: &state) case .fetchSearchResults(let items): @@ -291,11 +276,9 @@ private extension TodoListFeature { ) } - func setSearchTextEffect(_ text: String, state: inout State) -> Effect { - guard state.searchText != text else { return .none } - state.searchText = text + func setSearchTextEffect(state: inout State) -> Effect { state.showAllSearchResults = false - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = state.searchText.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { state.searchResults = [] diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index 8b44ca2e..a0ed3d55 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -38,14 +38,8 @@ struct TodoListView: View { } } .searchable( - text: Binding( - get: { store.state.searchText }, - set: { store.send(.setSearchText($0)) } - ), - isPresented: Binding( - get: { store.state.isSearching }, - set: { store.send(.setIsSearching($0)) } - ), + text: $store.searchText, + isPresented: $store.isSearching, placement: .navigationBarDrawer(displayMode: .always), prompt: Text( String.localizedStringWithFormat( @@ -58,19 +52,14 @@ struct TodoListView: View { } .alert( store.state.alertTitle, - isPresented: Binding( - get: { store.state.showAlert }, - set: { store.send(.setAlert($0)) } - )) { + isPresented: $store.showAlert + ) { Button(String(localized: "common_close"), role: .cancel) { } } message: { Text(store.state.alertMessage) } .navigationTitle(TodoCategoryItem(from: store.category).localizedName) - .fullScreenCover(isPresented: Binding( - get: { store.state.showEditor }, - set: { store.send(.setShowEditor($0)) } - )) { + .fullScreenCover(isPresented: $store.showEditor) { TodoEditorView( store: Store(initialState: TodoEditorFeature.State(category: store.category)) { TodoEditorFeature() @@ -81,7 +70,7 @@ struct TodoListView: View { $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) }, onCreateSuccess: { - store.send(.setShowEditor(false)) + store.send(.binding(.set(\.showEditor, false))) store.send(.refresh) } ) @@ -100,7 +89,7 @@ struct TodoListView: View { if #available(iOS 18, *) { ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.setIsSearching(true)) + store.send(.binding(.set(\.isSearching, true))) } label: { Image(systemName: "magnifyingglass") } @@ -217,14 +206,8 @@ struct TodoListView: View { private var todoSearchContent: some View { searchResultsContent .searchable( - text: Binding( - get: { store.state.searchText }, - set: { store.send(.setSearchText($0)) } - ), - isPresented: Binding( - get: { store.state.isSearching }, - set: { store.send(.setIsSearching($0)) } - ), + text: $store.searchText, + isPresented: $store.isSearching, placement: .navigationBarDrawer(displayMode: .always), prompt: Text( String.localizedStringWithFormat( @@ -242,7 +225,7 @@ struct TodoListView: View { value: TodoEditorWindowValue(todoCategory: store.category, source: .list) ) } else { - store.send(.setShowEditor(true)) + store.send(.binding(.set(\.showEditor, true))) } } @@ -302,7 +285,7 @@ struct TodoListView: View { if !store.state.showAllSearchResults, limit < searchResults.count { Button(String(localized: "todo_list_show_more")) { - store.send(.setShowAllSearchResults(true)) + store.send(.binding(.set(\.showAllSearchResults, true))) } .font(.subheadline) .foregroundStyle(Color.gray) @@ -358,20 +341,14 @@ struct TodoListView: View { private var sortMenu: some View { Menu { - Picker(selection: Binding( - get: { store.state.query.sortTarget }, - set: { store.send(.setSortTarget($0)) } - )) { + Picker(selection: $store.query.sortTarget) { ForEach([TodoQuery.SortTarget.createdAt, .updatedAt], id: \.self) { option in Text(option.title).tag(option) } } label: { Text(String(localized: "todo_list_sort_by")) } - Picker(selection: Binding( - get: { store.state.query.sortOrder }, - set: { store.send(.setSortOrder($0)) } - )) { + Picker(selection: $store.query.sortOrder) { ForEach([TodoQuery.SortOrder.latest, .oldest], id: \.self) { option in Text(option.title).tag(option) } @@ -397,17 +374,11 @@ struct TodoListView: View { private var filterMenu: some View { Menu { - Toggle(isOn: Binding( - get: { store.state.query.isPinned == true }, - set: { _ in store.send(.togglePinnedOnly) } - )) { + Toggle(isOn: $store.isPinnedOnly) { Text(String(localized: "todo_pinned")) } - Picker(selection: Binding( - get: { store.state.query.completionFilter }, - set: { store.send(.setCompletionFilter($0)) } - )) { + Picker(selection: $store.query.completionFilter) { ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in Text(option.title).tag(option) } diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index 0ddb8e09..28157a34 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -64,22 +64,22 @@ final class TodoListStoreTestAdapter { } func setSortTarget(_ target: TodoQuery.SortTarget) async { - await store.send(.setSortTarget(target)) + await store.send(.binding(.set(\.query.sortTarget, target))) await drainReceivedActions() } func setSortOrder(_ order: TodoQuery.SortOrder) async { - await store.send(.setSortOrder(order)) + await store.send(.binding(.set(\.query.sortOrder, order))) await drainReceivedActions() } func togglePinnedOnly() async { - await store.send(.togglePinnedOnly) + await store.send(.binding(.set(\.isPinnedOnly, !store.state.isPinnedOnly))) await drainReceivedActions() } func setCompletionFilter(_ filter: TodoQuery.CompletionFilter) async { - await store.send(.setCompletionFilter(filter)) + await store.send(.binding(.set(\.query.completionFilter, filter))) await drainReceivedActions() } @@ -90,7 +90,7 @@ final class TodoListStoreTestAdapter { func setSearchText(_ text: String) async { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - await store.send(.setSearchText(text)) + await store.send(.binding(.set(\.searchText, text))) await drainReceivedActions() if !trimmed.isEmpty { @@ -104,12 +104,12 @@ final class TodoListStoreTestAdapter { } func setIsSearching(_ value: Bool) async { - await store.send(.setIsSearching(value)) + await store.send(.binding(.set(\.isSearching, value))) await drainReceivedActions() } func setShowAllSearchResults(_ value: Bool) async { - await store.send(.setShowAllSearchResults(value)) + await store.send(.binding(.set(\.showAllSearchResults, value))) } func appendTodos(_ todos: [TodoListItem]) async { From f56d27d6c477eb88cf013a50924c14127f6ef63a Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:06:02 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20AlertState=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/List/TodoListFeature+Effects.swift | 16 +++++++++++++--- .../Sources/Home/List/TodoListFeature.swift | 14 ++++++-------- .../Sources/Home/List/TodoListView.swift | 9 +-------- .../Tests/Home/TodoListFeatureTestDoubles.swift | 17 ++++++++++++++--- .../Tests/Home/TodoListFeatureTests.swift | 4 ++-- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift index c9aad43a..673bf419 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift @@ -119,9 +119,19 @@ extension TodoListFeature { _ state: inout State, isPresented: Bool ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented + state.alert = isPresented ? Self.alertState() : nil + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } } static func setTodoHidden( diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index bd7485bf..d52d5736 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -14,14 +14,12 @@ import Foundation struct TodoListFeature { @ObservableState struct State: Equatable { + @Presents var alert: AlertState? var category: TodoCategory var todos: [TodoListItem] = [] var searchText = "" var searchResults: [TodoListItem] = [] var showEditor = false - var showAlert = false - var alertTitle = "" - var alertMessage = "" var isSearching = false var showAllSearchResults = false var query: TodoQuery @@ -61,9 +59,7 @@ struct TodoListFeature { lhs.searchText == rhs.searchText && lhs.searchResults == rhs.searchResults && lhs.showEditor == rhs.showEditor && - lhs.showAlert == rhs.showAlert && - lhs.alertTitle == rhs.alertTitle && - lhs.alertMessage == rhs.alertMessage && + lhs.alert == rhs.alert && lhs.isSearching == rhs.isSearching && lhs.showAllSearchResults == rhs.showAllSearchResults && lhs.query == rhs.query && @@ -78,6 +74,7 @@ struct TodoListFeature { } enum Action: BindableAction { + case alert(PresentationAction) case binding(BindingAction) case refresh case setAlert(Bool) @@ -124,6 +121,7 @@ struct TodoListFeature { Reduce { state, action in reduce(action, state: &state) } + .ifLet(\.$alert, action: \.alert) } } @@ -177,6 +175,8 @@ private enum TodoListUndoDeleteTodoUseCaseKey: DependencyKey { private extension TodoListFeature { func reduce(_ action: Action, state: inout State) -> Effect { switch action { + case .alert: + break case .binding(\.searchText): return setSearchTextEffect(state: &state) case .binding(\.isSearching): @@ -185,8 +185,6 @@ private extension TodoListFeature { state.searchResults = [] state.showAllSearchResults = false return cancelSearchEffect() - case .binding(\.showAlert): - Self.setAlert(&state, isPresented: state.showAlert) case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.isPinnedOnly), .binding(\.query.completionFilter): state.nextCursor = nil diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index a0ed3d55..95afa592 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -50,14 +50,7 @@ struct TodoListView: View { ) } } - .alert( - store.state.alertTitle, - isPresented: $store.showAlert - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(store.state.alertMessage) - } + .alert($store.scope(state: \.alert, action: \.alert)) .navigationTitle(TodoCategoryItem(from: store.category).localizedName) .fullScreenCover(isPresented: $store.showEditor) { TodoEditorView( diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index 28157a34..be214e31 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -23,9 +23,8 @@ final class TodoListStoreTestAdapter { var query: TodoQuery { store.state.query } var isLoading: Bool { store.state.isLoading } var hasMore: Bool { store.state.hasMore } - var showAlert: Bool { store.state.showAlert } - var alertTitle: String { store.state.alertTitle } - var alertMessage: String { store.state.alertMessage } + var alert: AlertState? { store.state.alert } + var showAlert: Bool { store.state.alert != nil } var appliedFilterCount: Int { store.state.appliedFilterCount } init( @@ -148,6 +147,18 @@ final class TodoListStoreTestAdapter { } } +func expectedTodoListErrorAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } +} + final class TodoListFetchTodosUseCaseSpy: FetchTodosUseCase { var pages: [TodoPage] var error: Error? diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift index 4e3e1f71..dcb09587 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @testable import DevLogPresentation @@ -208,7 +209,6 @@ struct TodoListFeatureTests { } #expect(adapter.showAlert) - #expect(adapter.alertTitle == String(localized: "common_error_title")) - #expect(adapter.alertMessage == String(localized: "common_error_message")) + #expect(adapter.alert == expectedTodoListErrorAlert()) } } From 5b29054a444e48761633836978e823842af82754 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:18:15 +0900 Subject: [PATCH 6/9] =?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 --- .../Sources/Home/List/TodoListFeature.swift | 23 +++++++++- .../Sources/Home/List/TodoListView.swift | 44 ++++++++++++------- .../Home/TodoListFeatureTestDoubles.swift | 9 ++++ .../Tests/Home/TodoListFeatureTests.swift | 11 +++++ 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index d52d5736..cb259eb7 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -15,11 +15,11 @@ struct TodoListFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + @Presents var fullScreenCover: FullScreenCoverState? var category: TodoCategory var todos: [TodoListItem] = [] var searchText = "" var searchResults: [TodoListItem] = [] - var showEditor = false var isSearching = false var showAllSearchResults = false var query: TodoQuery @@ -58,8 +58,8 @@ struct TodoListFeature { lhs.todos == rhs.todos && lhs.searchText == rhs.searchText && lhs.searchResults == rhs.searchResults && - lhs.showEditor == rhs.showEditor && lhs.alert == rhs.alert && + lhs.fullScreenCover == rhs.fullScreenCover && lhs.isSearching == rhs.isSearching && lhs.showAllSearchResults == rhs.showAllSearchResults && lhs.query == rhs.query && @@ -73,11 +73,24 @@ struct TodoListFeature { } } + @ObservableState + struct FullScreenCoverState: Equatable { + var destination: Destination + + enum Destination: Equatable { + case editor + } + + static let editor = Self(destination: .editor) + } + enum Action: BindableAction { case alert(PresentationAction) + case fullScreenCover(PresentationAction) case binding(BindingAction) case refresh case setAlert(Bool) + case setFullScreenCover(FullScreenCoverState?) case swipeTodo(TodoListItem) case resetFilters case finishDeleteToast(String) @@ -177,6 +190,10 @@ private extension TodoListFeature { switch action { case .alert: break + case .fullScreenCover(.dismiss): + state.fullScreenCover = nil + case .fullScreenCover: + break case .binding(\.searchText): return setSearchTextEffect(state: &state) case .binding(\.isSearching): @@ -195,6 +212,8 @@ private extension TodoListFeature { return fetchEffect(query: state.query, cursor: nil) case .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: diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index 95afa592..6ee1b381 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -52,21 +52,10 @@ struct TodoListView: View { } .alert($store.scope(state: \.alert, action: \.alert)) .navigationTitle(TodoCategoryItem(from: store.category).localizedName) - .fullScreenCover(isPresented: $store.showEditor) { - TodoEditorView( - store: Store(initialState: TodoEditorFeature.State(category: store.category)) { - TodoEditorFeature() - } withDependencies: { - $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) - $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) - $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) - $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) - }, - onCreateSuccess: { - store.send(.binding(.set(\.showEditor, false))) - store.send(.refresh) - } - ) + .fullScreenCover( + item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + ) { coverStore in + fullScreenCoverContent(coverStore) } .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -211,6 +200,29 @@ struct TodoListView: View { ) } + @ViewBuilder + private func fullScreenCoverContent( + _ coverStore: Store + ) -> some View { + switch coverStore.destination { + case .editor: + TodoEditorView( + store: Store(initialState: TodoEditorFeature.State(category: store.category)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + }, + onCreateSuccess: { + store.send(.setFullScreenCover(nil)) + store.send(.refresh) + } + ) + } + } + private func openTodoEditor() { if isiOSAppOnMac { openWindow( @@ -218,7 +230,7 @@ struct TodoListView: View { value: TodoEditorWindowValue(todoCategory: store.category, source: .list) ) } else { - store.send(.binding(.set(\.showEditor, true))) + store.send(.setFullScreenCover(.editor)) } } diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index be214e31..04b3bf5d 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -24,6 +24,7 @@ final class TodoListStoreTestAdapter { var isLoading: Bool { store.state.isLoading } var hasMore: Bool { store.state.hasMore } var alert: AlertState? { store.state.alert } + var fullScreenCover: TodoListFeature.FullScreenCoverState? { store.state.fullScreenCover } var showAlert: Bool { store.state.alert != nil } var appliedFilterCount: Int { store.state.appliedFilterCount } @@ -115,6 +116,14 @@ final class TodoListStoreTestAdapter { await store.send(.appendTodos(todos, nextCursor: nil)) } + func setFullScreenCover(_ cover: TodoListFeature.FullScreenCoverState?) async { + await store.send(.setFullScreenCover(cover)) + } + + func dismissFullScreenCover() async { + await store.send(.fullScreenCover(.dismiss)) + } + func swipeTodo(_ todo: TodoListItem) async { await store.send(.swipeTodo(todo)) await store.send(.presentedDeleteToast) diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift index dcb09587..b7467019 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift @@ -125,6 +125,17 @@ struct TodoListFeatureTests { #expect(!adapter.isLoading) } + @Test("fullScreenCover 상태를 설정하고 dismiss 할 수 있다") + func fullScreenCover_상태를_설정하고_dismiss_할_수_있다() async { + let adapter = TodoListStoreTestAdapter() + + await adapter.setFullScreenCover(.editor) + #expect(adapter.fullScreenCover == .editor) + + await adapter.dismissFullScreenCover() + #expect(adapter.fullScreenCover == nil) + } + @Test("swipeTodo는 Todo를 숨기고 undoDelete와 finishDeleteToast는 숨김 상태를 되돌리거나 제거한다") func swipeTodo는_Todo를_숨기고_undoDelete와_finishDeleteToast는_숨김_상태를_되돌리거나_제거한다() async { let todo = makeTodoListTodo(id: "todo-delete") From 766d862435968db84af554058f171f4554dab16f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:47:49 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20isPinned=20=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=EC=97=90=EC=84=9C=20=EC=98=B5=EC=85=94=EB=84=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogCore/Sources/TodoQuery.swift | 4 ++-- .../DevLogInfra/Sources/Service/TodoServiceImpl.swift | 6 +++--- .../Sources/Home/List/TodoListFeature.swift | 9 ++------- .../Sources/Home/List/TodoListView.swift | 4 ++-- .../Tests/Home/TodoListFeatureTestDoubles.swift | 2 +- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Application/DevLogCore/Sources/TodoQuery.swift b/Application/DevLogCore/Sources/TodoQuery.swift index f3298371..afeb323d 100644 --- a/Application/DevLogCore/Sources/TodoQuery.swift +++ b/Application/DevLogCore/Sources/TodoQuery.swift @@ -35,7 +35,7 @@ public struct TodoQuery: Equatable, Sendable { public var categoryId: String? public var keyword: String? - public var isPinned: Bool? + public var isPinned: Bool public var completionFilter: CompletionFilter public var dueDateFilter: DueDateFilter public var sortDateFrom: Date? @@ -49,7 +49,7 @@ public struct TodoQuery: Equatable, Sendable { public init( categoryId: String? = nil, keyword: String? = nil, - isPinned: Bool? = nil, + isPinned: Bool = false, completionFilter: CompletionFilter = .all, dueDateFilter: DueDateFilter = .all, sortDateFrom: Date? = nil, diff --git a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift index d801e247..7327d8fd 100644 --- a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift @@ -35,7 +35,7 @@ final class TodoServiceImpl: TodoService { "sortOrder=\(query.sortOrder == .latest ? "latest" : "oldest")", query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil, query.categoryId != nil ? "category=\(query.categoryId!)" : nil, - query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, + query.isPinned ? "pinned=true" : nil, query.completionFilter.isCompletedValue != nil ? "completed=\(query.completionFilter.isCompletedValue!)" : nil, @@ -58,8 +58,8 @@ final class TodoServiceImpl: TodoService { ) } - if let isPinned = query.isPinned { - firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: isPinned) + if query.isPinned { + firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: true) } if let isCompleted = query.completionFilter.isCompletedValue { diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index cb259eb7..6077ee0a 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -43,16 +43,11 @@ struct TodoListFeature { var count = 0 if query.sortTarget != .createdAt { count += 1 } if query.sortOrder != .latest { count += 1 } - if query.isPinned != nil { count += 1 } + if query.isPinned { count += 1 } if query.completionFilter != .all { count += 1 } return count } - var isPinnedOnly: Bool { - get { query.isPinned == true } - set { query.isPinned = newValue ? true : nil } - } - static func == (lhs: Self, rhs: Self) -> Bool { lhs.category == rhs.category && lhs.todos == rhs.todos && @@ -202,7 +197,7 @@ private extension TodoListFeature { state.searchResults = [] state.showAllSearchResults = false return cancelSearchEffect() - case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.isPinnedOnly), + case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), .binding(\.query.completionFilter): state.nextCursor = nil return fetchEffect(query: state.query, cursor: nil) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index 6ee1b381..ef59baf8 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -379,7 +379,7 @@ struct TodoListView: View { private var filterMenu: some View { Menu { - Toggle(isOn: $store.isPinnedOnly) { + Toggle(isOn: $store.query.isPinned) { Text(String(localized: "todo_pinned")) } @@ -391,7 +391,7 @@ struct TodoListView: View { Text(String(localized: "todo_list_completion_status")) } } label: { - let condition = store.state.query.isPinned == true || store.state.query.completionFilter != .all + let condition = store.state.query.isPinned || store.state.query.completionFilter != .all HStack { Text(String(localized: "todo_list_filter_options")) Image(systemName: "chevron.down") diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index 04b3bf5d..6c48df3b 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -74,7 +74,7 @@ final class TodoListStoreTestAdapter { } func togglePinnedOnly() async { - await store.send(.binding(.set(\.isPinnedOnly, !store.state.isPinnedOnly))) + await store.send(.binding(.set(\.query.isPinned, !store.state.query.isPinned))) await drainReceivedActions() } From 14b24ceceb1e4bcd020cc42959298a76fb71ccbe Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:02:42 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=3D=3D=20=EC=97=B0=EC=82=B0=EC=9E=90=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Entity/TodoCursor.swift | 2 +- .../Sources/Home/List/TodoListFeature.swift | 19 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/Application/DevLogDomain/Sources/Entity/TodoCursor.swift b/Application/DevLogDomain/Sources/Entity/TodoCursor.swift index 04f76c14..961ac1d3 100644 --- a/Application/DevLogDomain/Sources/Entity/TodoCursor.swift +++ b/Application/DevLogDomain/Sources/Entity/TodoCursor.swift @@ -7,7 +7,7 @@ import Foundation -public struct TodoCursor { +public struct TodoCursor: Equatable { public let primarySortDate: Date? public let secondarySortDate: Date? public let documentID: String diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index 6077ee0a..cb7cef29 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -47,25 +47,6 @@ struct TodoListFeature { if query.completionFilter != .all { count += 1 } return count } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.category == rhs.category && - lhs.todos == rhs.todos && - lhs.searchText == rhs.searchText && - lhs.searchResults == rhs.searchResults && - lhs.alert == rhs.alert && - lhs.fullScreenCover == rhs.fullScreenCover && - lhs.isSearching == rhs.isSearching && - lhs.showAllSearchResults == rhs.showAllSearchResults && - lhs.query == rhs.query && - lhs.hasMore == rhs.hasMore && - lhs.loading == rhs.loading && - lhs.undoTodoId == rhs.undoTodoId && - lhs.deleteToastTodoId == rhs.deleteToastTodoId && - lhs.nextCursor?.primarySortDate == rhs.nextCursor?.primarySortDate && - lhs.nextCursor?.secondarySortDate == rhs.nextCursor?.secondarySortDate && - lhs.nextCursor?.documentID == rhs.nextCursor?.documentID - } } @ObservableState From 10828cf3ce332c4ff96b50c4c6c1b04ee8521360 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:12:36 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EA=B3=BC=EB=8F=84=ED=95=9C=20r?= =?UTF-8?q?efresh=20=EC=9A=94=EC=B2=AD=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20cancelId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/List/TodoListFeature.swift | 4 ++ .../Home/TodoListFeatureTestDoubles.swift | 44 +++++++++++++++++++ .../Tests/Home/TodoListFeatureTests.swift | 26 +++++++++++ 3 files changed, 74 insertions(+) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index cb7cef29..ef31edf5 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -89,6 +89,7 @@ struct TodoListFeature { enum CancelID: Hashable { case debounce + case fetch case request } @@ -261,12 +262,15 @@ private extension TodoListFeature { )) await send(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil)) await send(.loading(.end(target: .default, mode: .delayed))) + } catch is CancellationError { + return } catch { await send(.setAlert(true)) await send(.loading(.end(target: .default, mode: .delayed))) } } ) + .cancellable(id: CancelID.fetch, cancelInFlight: true) } func setSearchTextEffect(state: inout State) -> Effect { diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index 6c48df3b..4c913bdd 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -194,6 +194,50 @@ final class TodoListFetchTodosUseCaseSpy: FetchTodosUseCase { } } +actor TodoListDelayedFirstFetchTodosUseCaseSpy: FetchTodosUseCase { + private let pages: [TodoPage] + private var queries = [TodoQuery]() + private var cursors = [TodoCursor?]() + private var cancelledCallIndices = [Int]() + + init(pages: [TodoPage]) { + self.pages = pages + } + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + let callIndex = queries.count + queries.append(query) + cursors.append(cursor) + + if callIndex == 0 { + do { + try await Task.sleep(for: .seconds(1)) + } catch is CancellationError { + cancelledCallIndices.append(callIndex) + throw CancellationError() + } + } + + if pages.count <= callIndex { + return pages.last ?? TodoPage(items: [], nextCursor: nil) + } + + return pages[callIndex] + } + + func calledQueries() -> [TodoQuery] { + queries + } + + func calledCursors() -> [TodoCursor?] { + cursors + } + + func cancelledCalls() -> [Int] { + cancelledCallIndices + } +} + final class TodoListFetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { var todos: [Todo] var error: Error? diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift index b7467019..67b001d4 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTests.swift @@ -63,6 +63,32 @@ struct TodoListFeatureTests { #expect(!adapter.hasMore) } + @Test("새 목록 조회는 이전 요청을 취소하고 마지막 응답만 반영한다") + func 새_목록_조회는_이전_요청을_취소하고_마지막_응답만_반영한다() async { + let firstTodo = makeTodoListTodo(id: "todo-first", number: 1) + let secondTodo = makeTodoListTodo(id: "todo-second", number: 2) + let fetchSpy = TodoListDelayedFirstFetchTodosUseCaseSpy(pages: [ + TodoPage(items: [firstTodo], nextCursor: nil), + TodoPage(items: [secondTodo], nextCursor: nil) + ]) + let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.onAppear() + await adapter.setSortTarget(.updatedAt) + + await waitUntil(timeout: .seconds(2)) { + adapter.todos == [TodoListItem(from: secondTodo)!] + } + + let queries = await fetchSpy.calledQueries() + let cancelledCalls = await fetchSpy.cancelledCalls() + + #expect(adapter.todos == [TodoListItem(from: secondTodo)!]) + #expect(queries.map(\.sortTarget) == [.createdAt, .updatedAt]) + #expect(cancelledCalls == [0]) + #expect(!adapter.showAlert) + } + @Test("필터와 정렬 액션은 query와 적용 필터 수를 갱신한다") func 필터와_정렬_액션은_query와_적용_필터_수를_갱신한다() async { let adapter = TodoListStoreTestAdapter()