From 0b0ab8b7f3c22536cff59fde6663d61d446faab2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:37:26 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20TodoDetailFeature=20=EB=A6=AC?= =?UTF-8?q?=EB=93=80=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/LoadingState.swift | 2 +- .../Home/Detail/TodoDetailFeature.swift | 195 +++++++++ .../Tests/Home/TodoDetailFeatureTests.swift | 387 ++++++++++++++++++ 3 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift create mode 100644 Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift diff --git a/Application/DevLogPresentation/Sources/Common/LoadingState.swift b/Application/DevLogPresentation/Sources/Common/LoadingState.swift index 00aca6c5..b464302e 100644 --- a/Application/DevLogPresentation/Sources/Common/LoadingState.swift +++ b/Application/DevLogPresentation/Sources/Common/LoadingState.swift @@ -26,7 +26,7 @@ public final class LoadingState { private var visibleDelayedTargets = Set() private var visibleTargets = Set() - init(delay: Duration = .seconds(0.3)) { + nonisolated init(delay: Duration = .seconds(0.3)) { self.delay = delay } diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift new file mode 100644 index 00000000..ce1bc50c --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift @@ -0,0 +1,195 @@ +// +// TodoDetailFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/11/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation + +@Reducer +struct TodoDetailFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var sheet: SheetState? + @Presents var fullScreenCover: FullScreenCoverState? + var todoId: String + var showEditButton: Bool + var todo: Todo? + var referenceItems: [Int: TodoReferenceItem] = [:] + var isLoading = false + } + + @ObservableState + struct SheetState: Equatable { + var destination: Destination + + enum Destination: Equatable { + case info + case todo(TodoIdItem) + } + + static let info = Self(destination: .info) + + static func todo(_ todoId: TodoIdItem) -> Self { + Self(destination: .todo(todoId)) + } + } + + @ObservableState + struct FullScreenCoverState: Equatable { + var destination: Destination + + enum Destination: Equatable { + case editor + } + + static let editor = Self(destination: .editor) + } + + enum Action { + case alert(PresentationAction) + case sheet(PresentationAction) + case fullScreenCover(PresentationAction) + case onAppear + case fetchFailed + case setSheet(SheetState?) + case setFullScreenCover(FullScreenCoverState?) + case setTodo(Todo) + case setReferenceItems([Int: TodoReferenceItem]) + case setLoading(Bool) + + enum Sheet: Equatable { + case tapCloseButton + } + } + + @Dependency(\.fetchTodoByIdUseCase) var fetchTodoUseCase + @Dependency(\.fetchReferenceItemsUseCase) var fetchReferenceItemsUseCase + private let loadingState = LoadingState() + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .sheet(.dismiss): + state.sheet = nil + case .alert: + break + case .sheet(.presented(.tapCloseButton)): + state.sheet = nil + case .sheet: + break + case .fullScreenCover(.dismiss): + state.fullScreenCover = nil + case .fullScreenCover: + break + case .onAppear: + return fetchTodoEffect(todoId: state.todoId) + case .fetchFailed: + state.alert = alertState() + case .setSheet(let sheet): + state.sheet = sheet + case .setFullScreenCover(let cover): + state.fullScreenCover = cover + case .setTodo(let todo): + state.todo = todo + state.referenceItems = [:] + return resolveMarkdownEffect(content: todo.content) + case .setReferenceItems(let items): + state.referenceItems = items + case .setLoading(let value): + state.isLoading = value + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension DependencyValues { + var fetchTodoByIdUseCase: FetchTodoByIdUseCase { + get { self[FetchTodoByIdUseCaseKey.self] } + set { self[FetchTodoByIdUseCaseKey.self] = newValue } + } + + var fetchReferenceItemsUseCase: FetchReferenceItemsUseCase { + get { self[FetchReferenceItemsUseCaseKey.self] } + set { self[FetchReferenceItemsUseCaseKey.self] = newValue } + } +} + +private enum FetchTodoByIdUseCaseKey: DependencyKey { + static var liveValue: FetchTodoByIdUseCase { + preconditionFailure("FetchTodoByIdUseCase must be provided.") + } + + static var testValue: FetchTodoByIdUseCase { + liveValue + } +} + +private enum FetchReferenceItemsUseCaseKey: DependencyKey { + static var liveValue: FetchReferenceItemsUseCase { + preconditionFailure("FetchReferenceItemsUseCase must be provided.") + } + + static var testValue: FetchReferenceItemsUseCase { + liveValue + } +} + +private extension TodoDetailFeature { + func fetchTodoEffect(todoId: String) -> Effect { + .run { [fetchTodoUseCase, loadingState] send in + await loadingState.begin(mode: .delayed) { isLoading in + send(.setLoading(isLoading)) + } + do { + let todo = try await fetchTodoUseCase.execute(todoId) + await loadingState.end(mode: .delayed) { isLoading in + send(.setLoading(isLoading)) + } + await send(.setTodo(todo)) + } catch { + await loadingState.end(mode: .delayed) { isLoading in + send(.setLoading(isLoading)) + } + await send(.fetchFailed) + } + } + } + + func resolveMarkdownEffect(content: String) -> Effect { + .run { [fetchReferenceItemsUseCase] send in + let numbers = content.todoReferenceNumbers + var referenceItems = [Int: TodoReferenceItem]() + + if !numbers.isEmpty { + do { + referenceItems = try await fetchReferenceItemsUseCase.execute(numbers) + .mapValues(TodoReferenceItem.init(from:)) + } catch { + referenceItems = [:] + } + } + + await send(.setReferenceItems(referenceItems)) + } + } + + 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")) + } + } +} diff --git a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift new file mode 100644 index 00000000..65903e47 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift @@ -0,0 +1,387 @@ +// +// TodoDetailFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/11/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct TodoDetailFeatureTests { + @Test("화면이 나타나면 todoId로 Todo를 가져와 상태에 반영한다") + func 화면이_나타나면_todoId로_Todo를_가져와_상태에_반영한다() async { + let todo = makeTodo(id: "todo-1", content: "content") + let fetchSpy = FetchTodoByIdUseCaseSpy(todo: todo) + let referenceSpy = FetchReferenceItemsUseCaseSpy() + let adapter = TodoDetailStoreTestAdapter( + fetchUseCase: fetchSpy, + referenceUseCase: referenceSpy, + todoId: "todo-1" + ) + + adapter.onAppear() + + await waitUntil { + adapter.todo == todo + } + + #expect(fetchSpy.todoIds == ["todo-1"]) + #expect(adapter.todo == todo) + #expect(referenceSpy.numbers.isEmpty) + } + + @Test("Todo 본문에 참조 번호가 있으면 참조 항목을 가져와 상태에 반영한다") + func Todo_본문에_참조_번호가_있으면_참조_항목을_가져와_상태에_반영한다() async { + let todo = makeTodo( + content: """ + body + - refs #3 + - refs #5 + - refs #3 + """ + ) + let reference3 = makeTodoReference(id: "todo-3", title: "Reference 3") + let reference5 = makeTodoReference(id: "todo-5", title: "Reference 5") + let fetchSpy = FetchTodoByIdUseCaseSpy(todo: todo) + let referenceSpy = FetchReferenceItemsUseCaseSpy(references: [ + 3: reference3, + 5: reference5 + ]) + let adapter = TodoDetailStoreTestAdapter( + fetchUseCase: fetchSpy, + referenceUseCase: referenceSpy, + todoId: todo.id + ) + + adapter.onAppear() + + await waitUntil { + adapter.referenceItems.count == 2 + } + + #expect(referenceSpy.numbers == [[3, 5]]) + #expect(adapter.referenceItems[3] == TodoReferenceItem(from: reference3)) + #expect(adapter.referenceItems[5] == TodoReferenceItem(from: reference5)) + } + + @Test("Todo를 새로 설정하면 기존 참조 항목을 비우고 새 본문 기준으로 다시 해석한다") + func Todo를_새로_설정하면_기존_참조_항목을_비우고_새_본문_기준으로_다시_해석한다() async { + let reference7 = makeTodoReference(id: "todo-7", title: "Reference 7") + let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo()) + let referenceSpy = FetchReferenceItemsUseCaseSpy(references: [7: reference7]) + let adapter = TodoDetailStoreTestAdapter( + fetchUseCase: fetchSpy, + referenceUseCase: referenceSpy, + todoId: "todo-1" + ) + + adapter.setReferenceItems([99: TodoReferenceItem(from: makeTodoReference(id: "todo-99"))]) + adapter.setTodo(makeTodo(content: "- refs #7")) + + #expect(adapter.referenceItems.isEmpty) + + await waitUntil { + adapter.referenceItems[7] == TodoReferenceItem(from: reference7) + } + + #expect(referenceSpy.numbers == [[7]]) + } + + @Test("Todo 조회가 지연되면 로딩 상태를 표시하고 완료되면 해제한다") + func Todo_조회가_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async { + let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo()) + fetchSpy.shouldSuspend = true + let adapter = TodoDetailStoreTestAdapter( + fetchUseCase: fetchSpy, + referenceUseCase: FetchReferenceItemsUseCaseSpy(), + todoId: "todo-1" + ) + + adapter.onAppear() + + await waitUntil { + adapter.isLoading + } + + #expect(adapter.isLoading) + + fetchSpy.resume() + + await waitUntil { + !adapter.isLoading && adapter.todo != nil + } + + #expect(!adapter.isLoading) + } + + @Test("Todo 조회 실패 시 공통 에러 알림 상태를 설정한다") + func Todo_조회_실패_시_공통_에러_알림_상태를_설정한다() async { + let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo()) + fetchSpy.error = TodoDetailTestError.failure + let adapter = TodoDetailStoreTestAdapter( + fetchUseCase: fetchSpy, + referenceUseCase: FetchReferenceItemsUseCaseSpy(), + todoId: "todo-1" + ) + + adapter.onAppear() + + await waitUntil { + adapter.alert != nil + } + + #expect(adapter.alert == expectedErrorAlert()) + #expect(!adapter.isLoading) + } + + @Test("시트와 편집 화면 상태를 액션에 맞게 변경한다") + func 시트와_편집_화면_상태를_액션에_맞게_변경한다() { + let adapter = TodoDetailStoreTestAdapter( + fetchUseCase: FetchTodoByIdUseCaseSpy(todo: makeTodo()), + referenceUseCase: FetchReferenceItemsUseCaseSpy(), + todoId: "todo-1", + showEditButton: false + ) + + adapter.setSheet(.info) + adapter.setFullScreenCover(.editor) + + #expect(adapter.sheet == .info) + #expect(adapter.fullScreenCover == .editor) + #expect(!adapter.showEditButton) + + adapter.setSheet(.todo(TodoIdItem(id: "todo-2"))) + + #expect(adapter.sheet == .todo(TodoIdItem(id: "todo-2"))) + + adapter.dismissSheet() + adapter.dismissFullScreenCover() + + #expect(adapter.sheet == nil) + #expect(adapter.fullScreenCover == nil) + } +} + +@MainActor +private protocol TodoDetailTestAdapter { + var todoId: String { get } + var showEditButton: Bool { get } + var todo: Todo? { get } + var referenceItems: [Int: TodoReferenceItem] { get } + var isLoading: Bool { get } + var alert: AlertState? { get } + var sheet: TodoDetailFeature.SheetState? { get } + var fullScreenCover: TodoDetailFeature.FullScreenCoverState? { get } + + func onAppear() + func setSheet(_ sheet: TodoDetailFeature.SheetState?) + func dismissSheet() + func setFullScreenCover(_ cover: TodoDetailFeature.FullScreenCoverState?) + func dismissFullScreenCover() + func setTodo(_ todo: Todo) + func setReferenceItems(_ items: [Int: TodoReferenceItem]) +} + +@MainActor +private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter { + private let store: StoreOf + + var todoId: String { + store.todoId + } + + var showEditButton: Bool { + store.showEditButton + } + + var todo: Todo? { + store.todo + } + + var referenceItems: [Int: TodoReferenceItem] { + store.referenceItems + } + + var isLoading: Bool { + store.isLoading + } + + var alert: AlertState? { + store.alert + } + + var sheet: TodoDetailFeature.SheetState? { + store.sheet + } + + var fullScreenCover: TodoDetailFeature.FullScreenCoverState? { + store.fullScreenCover + } + + init( + fetchUseCase: FetchTodoByIdUseCase, + referenceUseCase: FetchReferenceItemsUseCase, + todoId: String, + showEditButton: Bool = true + ) { + store = Store( + initialState: TodoDetailFeature.State( + todoId: todoId, + showEditButton: showEditButton + ) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = fetchUseCase + $0.fetchReferenceItemsUseCase = referenceUseCase + } + } + + func onAppear() { + store.send(.onAppear) + } + + func setSheet(_ sheet: TodoDetailFeature.SheetState?) { + store.send(.setSheet(sheet)) + } + + func dismissSheet() { + store.send(.sheet(.dismiss)) + } + + func setFullScreenCover(_ cover: TodoDetailFeature.FullScreenCoverState?) { + store.send(.setFullScreenCover(cover)) + } + + func dismissFullScreenCover() { + store.send(.fullScreenCover(.dismiss)) + } + + func setTodo(_ todo: Todo) { + store.send(.setTodo(todo)) + } + + func setReferenceItems(_ items: [Int: TodoReferenceItem]) { + store.send(.setReferenceItems(items)) + } +} + +private func expectedErrorAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } +} + +private final class FetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { + var todo: Todo + var error: Error? + var shouldSuspend = false + private(set) var todoIds: [String] = [] + private var continuation: CheckedContinuation? + private var shouldResume = false + + init(todo: Todo) { + self.todo = todo + } + + func execute(_ todoId: String) async throws -> Todo { + todoIds.append(todoId) + + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + + if let error { + throw error + } + + return todo + } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } +} + +private final class FetchReferenceItemsUseCaseSpy: FetchReferenceItemsUseCase { + var references: [Int: TodoReference] + var error: Error? + private(set) var numbers: [[Int]] = [] + + init(references: [Int: TodoReference] = [:]) { + self.references = references + } + + func execute(_ numbers: [Int]) async throws -> [Int: TodoReference] { + self.numbers.append(numbers) + + if let error { + throw error + } + + return references + } +} + +private enum TodoDetailTestError: Error { + case failure +} + +private func makeTodo( + id: String = "todo-1", + number: Int = 1, + title: String = "Todo", + content: String = "content" +) -> Todo { + Todo( + id: id, + isPinned: false, + isCompleted: false, + isChecked: false, + number: number, + title: title, + content: content, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.issue) + ) +} + +private func makeTodoReference( + id: String, + title: String = "Reference" +) -> TodoReference { + TodoReference( + id: id, + title: title, + category: .system(.issue) + ) +} From fb64f75e8443bb63cae0f253199e90eb88485c1e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:38:25 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20TodoDetailViewModel=EC=9D=84=20?= =?UTF-8?q?TodoDetailFeature=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Detail/TodoDetailView.swift | 146 +++++++++++------- .../Home/Detail/TodoDetailViewModel.swift | 143 ----------------- .../Sources/Home/Editor/TodoEditorView.swift | 4 +- .../Sources/Main/MainView.swift | 8 +- .../Sources/Profile/ProfileView.swift | 2 +- .../Profile/ProfileViewCoordinator.swift | 20 ++- .../PushNotificationListView.swift | 2 +- .../PushNotificationListViewCoordinator.swift | 36 +++-- .../Sources/Root/RootView.swift | 4 +- .../Sources/Search/SearchView.swift | 4 +- .../WindowGroup/TodoWindowCoordinator.swift | 43 +++--- 11 files changed, 160 insertions(+), 252 deletions(-) delete mode 100644 Application/DevLogPresentation/Sources/Home/Detail/TodoDetailViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 80fcb9f9..7e232fea 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -13,69 +14,56 @@ struct TodoDetailView: View { @Environment(\.diContainer) private var container: DIContainer @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @State var viewModel: TodoDetailViewModel + @State private var store: StoreOf + + init(store: StoreOf) { + self._store = State(initialValue: store) + } + + init( + fetchTodoUseCase: FetchTodoByIdUseCase, + fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, + todoId: String, + showEditButton: Bool = true + ) { + self.init(store: Store( + initialState: TodoDetailFeature.State( + todoId: todoId, + showEditButton: showEditButton + ) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = fetchTodoUseCase + $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + }) + } var body: some View { ZStack { Color(.systemGroupedBackground).ignoresSafeArea() - if let todo = viewModel.state.todo { + if let todo = store.todo { TodoDetailContentView( title: todo.title, content: todo.content, - referenceItems: viewModel.state.referenceItems, + referenceItems: store.referenceItems, number: todo.number, - onOpenTodoID: { viewModel.send(.setSelectedTodoId(TodoIdItem(id: $0))) } + onOpenTodoID: { store.send(.setSheet(.todo(TodoIdItem(id: $0)))) } ) - } else if viewModel.state.isLoading { + } else if store.isLoading { LoadingView() } } - .onAppear { viewModel.send(.onAppear) } + .onAppear { store.send(.onAppear) } .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: Binding( - get: { viewModel.state.showInfo }, - set: { viewModel.send(.setShowInfo($0)) } - )) { - sheetContent - } - .sheet(item: Binding( - get: { viewModel.state.selectedTodoId }, - set: { viewModel.send(.setSelectedTodoId($0)) } - )) { item in - NavigationStack { - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: item.id, - showEditButton: false - )) - .toolbar { - ToolbarLeadingButton { - viewModel.send(.setSelectedTodoId(nil)) - } - } - } - .background(Color(.systemGroupedBackground)) - .presentationDragIndicator(.visible) + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in + sheetContent(sheetStore) } - .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showEditor }, - set: { viewModel.send(.setShowEditor($0)) } - )) { - if let todo = viewModel.state.todo { - TodoEditorView( - viewModel: TodoEditorViewModel( - todo: todo, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - onUpdateSuccess: { todo in - viewModel.send(.setShowEditor(false)) - viewModel.send(.setTodo(todo)) - } - ) - ) - } + .fullScreenCover( + item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + ) { coverStore in + fullScreenCoverContent(coverStore) } .toolbar { toolbarContent } } @@ -84,12 +72,12 @@ struct TodoDetailView: View { private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - viewModel.send(.setShowInfo(true)) + store.send(.setSheet(.info)) } label: { Image(systemName: "info.circle") } } - if viewModel.showEditButton { + if store.showEditButton { if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarTrailing) } @@ -105,22 +93,66 @@ struct TodoDetailView: View { private func openTodoEditor() { if isiOSAppOnMac { - guard let todo = viewModel.state.todo else { return } + guard let todo = store.todo else { return } openWindow( id: TodoEditorWindowValue.sceneId, value: TodoEditorWindowValue(todo: todo) ) } else { - viewModel.send(.setShowEditor(true)) + store.send(.setFullScreenCover(.editor)) + } + } + + @ViewBuilder + private func fullScreenCoverContent( + _ coverStore: Store + ) -> some View { + switch coverStore.destination { + case .editor: + if let todo = store.todo { + TodoEditorView( + viewModel: TodoEditorViewModel( + todo: todo, + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + onUpdateSuccess: { todo in + store.send(.setFullScreenCover(nil)) + store.send(.setTodo(todo)) + } + ) + ) + } } } @ViewBuilder - private var sheetContent: some View { - if let todo = viewModel.state.todo { - TodoDetailInfoSheetView(todo: todo) { - viewModel.send(.setShowInfo(false)) + private func sheetContent( + _ sheetStore: Store + ) -> some View { + switch sheetStore.destination { + case .info: + if let todo = store.todo { + TodoDetailInfoSheetView(todo: todo) { + sheetStore.send(.tapCloseButton) + } } + case .todo(let item): + NavigationStack { + TodoDetailView( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + todoId: item.id, + showEditButton: false + ) + .toolbar { + ToolbarLeadingButton { + sheetStore.send(.tapCloseButton) + } + } + } + .background(Color(.systemGroupedBackground)) + .presentationDragIndicator(.visible) } } } diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailViewModel.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailViewModel.swift deleted file mode 100644 index dc206b2c..00000000 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailViewModel.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// TodoDetailViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 2/15/26. -// - -import Foundation -import DevLogDomain - -@Observable -final class TodoDetailViewModel: StorePattern { - struct State: Equatable { - var todo: Todo? - var selectedTodoId: TodoIdItem? - var referenceItems: [Int: TodoReferenceItem] = [:] - var isLoading: Bool = false - var showAlert: Bool = false - var showEditor: Bool = false - var showInfo: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - } - - enum Action { - case onAppear - case setAlert(Bool) - case setShowEditor(Bool) - case setShowInfo(Bool) - case setSelectedTodoId(TodoIdItem?) - case setTodo(Todo) - case setReferenceItems([Int: TodoReferenceItem]) - case setLoading(Bool) - } - - enum SideEffect { - case fetchTodo - case resolveMarkdown(String) - } - - private(set) var state: State = .init() - let todoId: String - let showEditButton: Bool - private let fetchTodoUseCase: FetchTodoByIdUseCase - private let fetchReferenceItemsUseCase: FetchReferenceItemsUseCase - private let loadingState = LoadingState() - - init( - fetchTodoUseCase: FetchTodoByIdUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - todoId: String, - showEditButton: Bool = true - ) { - self.fetchTodoUseCase = fetchTodoUseCase - self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - self.todoId = todoId - self.showEditButton = showEditButton - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .onAppear: - effects = [.fetchTodo] - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .setShowEditor(let isPresented): - state.showEditor = isPresented - case .setShowInfo(let presented): - state.showInfo = presented - case .setSelectedTodoId(let todoId): - state.selectedTodoId = todoId - case .setTodo(let todo): - state.todo = todo - state.referenceItems = [:] - effects = [.resolveMarkdown(todo.content)] - case .setReferenceItems(let items): - state.referenceItems = items - case .setLoading(let value): - state.isLoading = value - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchTodo: - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - let todo = try await fetchTodoUseCase.execute(todoId) - send(.setTodo(todo)) - } catch { - send(.setAlert(true)) - } - } - case .resolveMarkdown(let content): - Task { - let numbers = content.todoReferenceNumbers - var referenceItems = [Int: TodoReferenceItem]() - - if !numbers.isEmpty { - do { - referenceItems = try await fetchReferenceItemsUseCase.execute(numbers) - .mapValues(TodoReferenceItem.init(from:)) - } catch { - referenceItems = [:] - } - } - - send(.setReferenceItems(referenceItems)) - } - } - } -} - -private extension TodoDetailViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - private func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - private func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 2a569439..0d85c8b1 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -61,12 +61,12 @@ struct TodoEditorView: View { set: { viewModel.send(.setSelectedTodoId($0)) } )) { item in NavigationStack { - TodoDetailView(viewModel: TodoDetailViewModel( + TodoDetailView( fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), todoId: item.id, showEditButton: false - )) + ) .toolbar { ToolbarLeadingButton { viewModel.send(.setSelectedTodoId(nil)) diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 80d9d11e..d23ea979 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -232,7 +232,7 @@ struct MainView: View { ) .id(item.id) case .todo(let item): - TodoDetailView(viewModel: todoWindowCoordinator.makeDetailViewModel(todoId: item.id)) + TodoDetailView(store: todoWindowCoordinator.makeDetailStore(todoId: item.id)) .id(item.id) case .webPage(let item): WebView(url: item.url) @@ -295,7 +295,7 @@ struct MainView: View { private func todayDestinationView(_ todayRoute: TodayRoute) -> some View { switch todayRoute { case .todo(let item): - TodoDetailView(viewModel: todoWindowCoordinator.makeDetailViewModel(todoId: item.id)) + TodoDetailView(store: todoWindowCoordinator.makeDetailStore(todoId: item.id)) .id(item.id) } } @@ -311,7 +311,7 @@ struct MainView: View { private var notificationRegularDetailView: some View { if let todoId = pushNotificationListViewCoordinator.todoIdToPresent?.id { TodoDetailView( - viewModel: pushNotificationListViewCoordinator.makeTodoDetailViewModel( + store: pushNotificationListViewCoordinator.makeTodoDetailStore( todoId: todoId ) ) @@ -358,7 +358,7 @@ struct MainView: View { private func profileRegularDestinationView(_ route: ProfileRoute) -> some View { switch route { case .activity(let todoId): - TodoDetailView(viewModel: profileViewCoordinator.makeTodoDetailViewModel(todoId: todoId)) + TodoDetailView(store: profileViewCoordinator.makeTodoDetailStore(todoId: todoId)) .id(todoId) case .settings: SettingsView(viewModel: profileViewCoordinator.settingsViewModel) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index c4d4f21e..da88acd1 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -161,7 +161,7 @@ struct ProfileView: View { SettingsView(viewModel: coordinator.settingsViewModel) .environment(coordinator.router) case .activity(let todoId): - TodoDetailView(viewModel: coordinator.makeTodoDetailViewModel(todoId: todoId)) + TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: todoId)) case .theme: ThemeView( theme: Binding( diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift index beaea6c7..897a38b5 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift @@ -6,6 +6,7 @@ // import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -58,12 +59,17 @@ final class ProfileViewCoordinator { ) } - func makeTodoDetailViewModel(todoId: String) -> TodoDetailViewModel { - TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: todoId, - showEditButton: false - ) + func makeTodoDetailStore(todoId: String) -> StoreOf { + Store( + initialState: TodoDetailFeature.State( + todoId: todoId, + showEditButton: false + ) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = self.container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = self.container.resolve(FetchReferenceItemsUseCase.self) + } } } diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 0673954a..f601a095 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -55,7 +55,7 @@ struct PushNotificationListView: View { } )) { item in NavigationStack { - TodoDetailView(viewModel: coordinator.makeTodoDetailViewModel(todoId: item.id)) + TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: item.id)) .id(item.id) .toolbar { ToolbarLeadingButton { diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift index 23b9bee8..4dea112a 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -6,6 +6,7 @@ // import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -16,7 +17,7 @@ final class PushNotificationListViewCoordinator { var todoIdToPresent: TodoIdItem? private let container: DIContainer @ObservationIgnored - private var todoDetailViewModel: TodoDetailViewModel? + private var todoDetailStore: StoreOf? init(container: DIContainer) { self.container = container @@ -34,20 +35,27 @@ final class PushNotificationListViewCoordinator { viewModel.send(.fetchNotifications) } - func makeTodoDetailViewModel(todoId: String) -> TodoDetailViewModel { - if let todoDetailViewModel, - todoDetailViewModel.todoId == todoId, - !todoDetailViewModel.showEditButton { - return todoDetailViewModel + func makeTodoDetailStore(todoId: String) -> StoreOf { + if let todoDetailStore, + todoDetailStore.todoId == todoId, + !todoDetailStore.showEditButton { + return todoDetailStore } - let todoDetailViewModel = TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: todoId, - showEditButton: false - ) - self.todoDetailViewModel = todoDetailViewModel - return todoDetailViewModel + let fetchTodoUseCase = container.resolve(FetchTodoByIdUseCase.self) + let fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + let todoDetailStore = Store( + initialState: TodoDetailFeature.State( + todoId: todoId, + showEditButton: false + ) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = fetchTodoUseCase + $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + } + self.todoDetailStore = todoDetailStore + return todoDetailStore } } diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index 21374dd2..a4a8f1de 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -89,12 +89,12 @@ public struct RootView: View { switch route { case .todoDetail(let todoId): NavigationStack { - TodoDetailView(viewModel: TodoDetailViewModel( + TodoDetailView( fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), todoId: todoId, showEditButton: false - )) + ) .toolbar { ToolbarLeadingButton { selectedRoute = nil diff --git a/Application/DevLogPresentation/Sources/Search/SearchView.swift b/Application/DevLogPresentation/Sources/Search/SearchView.swift index 27616391..c1d6f1d6 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchView.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchView.swift @@ -21,11 +21,11 @@ struct SearchView: View { .navigationDestination(for: Path.self) { path in switch path { case .todo(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( + TodoDetailView( fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), todoId: todoId - )) + ) case .web(let page): WebView(url: page.url) .ignoresSafeArea() diff --git a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift index 0dacaca3..22d1bb0a 100644 --- a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift +++ b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -17,7 +18,7 @@ final class TodoWindowCoordinator { @ObservationIgnored private var listViewModel: TodoListViewModel? @ObservationIgnored - private var detailViewModel: TodoDetailViewModel? + private var detailStore: StoreOf? @ObservationIgnored private var cancellable: AnyCancellable? @@ -53,24 +54,28 @@ final class TodoWindowCoordinator { return listViewModel } - func makeDetailViewModel( + func makeDetailStore( todoId: String, showEditButton: Bool = true - ) -> TodoDetailViewModel { - if let detailViewModel, - detailViewModel.todoId == todoId, - detailViewModel.showEditButton == showEditButton { - return detailViewModel + ) -> StoreOf { + if let detailStore, + detailStore.todoId == todoId, + detailStore.showEditButton == showEditButton { + return detailStore } - - let detailViewModel = TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: todoId, - showEditButton: showEditButton - ) - self.detailViewModel = detailViewModel - return detailViewModel + let detailStore = Store( + initialState: TodoDetailFeature.State( + todoId: todoId, + showEditButton: showEditButton + ) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = self.container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = self.container.resolve(FetchReferenceItemsUseCase.self) + } + self.detailStore = detailStore + return detailStore } private func handleTodoEditorSubmit(_ submit: TodoEditorWindowSubmit) { @@ -81,9 +86,9 @@ final class TodoWindowCoordinator { listViewModel.send(.refresh) } case .update(let value, let todo): - if let detailViewModel, - value.matchesEdit(todoId: detailViewModel.todoId) { - detailViewModel.send(.setTodo(todo)) + if let detailStore, + value.matchesEdit(todoId: detailStore.todoId) { + detailStore.send(.setTodo(todo)) } } } From 16424fd363faa1487bfdd322bfd37177db9e6d63 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:39:47 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20TodoDetail=20Store=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20=EB=B0=A9=EC=8B=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Detail/TodoDetailView.swift | 33 +++++-------------- .../Sources/Home/Editor/TodoEditorView.swift | 15 +++++---- .../Sources/Root/RootView.swift | 14 ++++---- .../Sources/Search/SearchView.swift | 14 +++++--- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 7e232fea..cedaef45 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -20,25 +20,6 @@ struct TodoDetailView: View { self._store = State(initialValue: store) } - init( - fetchTodoUseCase: FetchTodoByIdUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - todoId: String, - showEditButton: Bool = true - ) { - self.init(store: Store( - initialState: TodoDetailFeature.State( - todoId: todoId, - showEditButton: showEditButton - ) - ) { - TodoDetailFeature() - } withDependencies: { - $0.fetchTodoByIdUseCase = fetchTodoUseCase - $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - }) - } - var body: some View { ZStack { Color(.systemGroupedBackground).ignoresSafeArea() @@ -139,12 +120,14 @@ struct TodoDetailView: View { } case .todo(let item): NavigationStack { - TodoDetailView( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: item.id, - showEditButton: false - ) + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + }) .toolbar { ToolbarLeadingButton { sheetStore.send(.tapCloseButton) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 0d85c8b1..7a757615 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -8,6 +8,7 @@ import MarkdownUI import OrderedCollections import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -61,12 +62,14 @@ struct TodoEditorView: View { set: { viewModel.send(.setSelectedTodoId($0)) } )) { item in NavigationStack { - TodoDetailView( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: item.id, - showEditButton: false - ) + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + }) .toolbar { ToolbarLeadingButton { viewModel.send(.setSelectedTodoId(nil)) diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index a4a8f1de..9175d1b7 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -89,12 +89,14 @@ public struct RootView: View { switch route { case .todoDetail(let todoId): NavigationStack { - TodoDetailView( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: todoId, - showEditButton: false - ) + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: false) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + }) .toolbar { ToolbarLeadingButton { selectedRoute = nil diff --git a/Application/DevLogPresentation/Sources/Search/SearchView.swift b/Application/DevLogPresentation/Sources/Search/SearchView.swift index c1d6f1d6..7355b791 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchView.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -21,11 +22,14 @@ struct SearchView: View { .navigationDestination(for: Path.self) { path in switch path { case .todo(let todoId): - TodoDetailView( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - todoId: todoId - ) + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: true) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + }) case .web(let page): WebView(url: page.url) .ignoresSafeArea() From 00b46c17edfa52d6854f62222d94298ca72bed1f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:14:20 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20TodoDetail=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20Store=20=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Detail/TodoDetailFeature.swift | 41 +++++++++++++++++-- .../Sources/Home/Detail/TodoDetailView.swift | 21 ++++------ .../Tests/Home/TodoDetailFeatureTests.swift | 19 +++++++-- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift index ce1bc50c..753776a7 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift @@ -27,15 +27,33 @@ struct TodoDetailFeature { struct SheetState: Equatable { var destination: Destination + var todoDetail: TodoDetailFeature.State? { + get { + guard case .todo(let state) = destination else { return nil } + return state + } + set { + guard let newValue else { return } + destination = .todo(newValue) + } + } + enum Destination: Equatable { case info - case todo(TodoIdItem) + case todo(TodoDetailFeature.State) } static let info = Self(destination: .info) static func todo(_ todoId: TodoIdItem) -> Self { - Self(destination: .todo(todoId)) + Self( + destination: .todo( + TodoDetailFeature.State( + todoId: todoId.id, + showEditButton: false + ) + ) + ) } } @@ -62,8 +80,10 @@ struct TodoDetailFeature { case setReferenceItems([Int: TodoReferenceItem]) case setLoading(Bool) - enum Sheet: Equatable { + @CasePathable + enum Sheet { case tapCloseButton + case todo(TodoDetailFeature.Action) } } @@ -107,6 +127,21 @@ struct TodoDetailFeature { return .none } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$sheet, action: \.sheet) { + TodoDetailSheetFeature() + } + } +} + +private struct TodoDetailSheetFeature: Reducer { + typealias State = TodoDetailFeature.SheetState + typealias Action = TodoDetailFeature.Action.Sheet + + var body: some ReducerOf { + EmptyReducer() + .ifLet(\.todoDetail, action: \.todo) { + TodoDetailFeature() + } } } diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index cedaef45..b9c96497 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -118,20 +118,15 @@ struct TodoDetailView: View { sheetStore.send(.tapCloseButton) } } - case .todo(let item): + case .todo: NavigationStack { - TodoDetailView(store: Store( - initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) - ) { - TodoDetailFeature() - } withDependencies: { - $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) - $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) - }) - .toolbar { - ToolbarLeadingButton { - sheetStore.send(.tapCloseButton) - } + if let todoStore = sheetStore.scope(state: \.todoDetail, action: \.todo) { + TodoDetailView(store: todoStore) + .toolbar { + ToolbarLeadingButton { + sheetStore.send(.tapCloseButton) + } + } } } .background(Color(.systemGroupedBackground)) diff --git a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift index 65903e47..d4da87b3 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift @@ -140,9 +140,10 @@ struct TodoDetailFeatureTests { } @Test("시트와 편집 화면 상태를 액션에 맞게 변경한다") - func 시트와_편집_화면_상태를_액션에_맞게_변경한다() { + func 시트와_편집_화면_상태를_액션에_맞게_변경한다() async { + let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo(id: "todo-2")) let adapter = TodoDetailStoreTestAdapter( - fetchUseCase: FetchTodoByIdUseCaseSpy(todo: makeTodo()), + fetchUseCase: fetchSpy, referenceUseCase: FetchReferenceItemsUseCaseSpy(), todoId: "todo-1", showEditButton: false @@ -157,7 +158,16 @@ struct TodoDetailFeatureTests { adapter.setSheet(.todo(TodoIdItem(id: "todo-2"))) - #expect(adapter.sheet == .todo(TodoIdItem(id: "todo-2"))) + #expect(adapter.sheet?.todoDetail?.todoId == "todo-2") + #expect(adapter.sheet?.todoDetail?.showEditButton == false) + + adapter.onSheetTodoAppear() + + await waitUntil { + adapter.sheet?.todoDetail?.todo?.id == "todo-2" + } + + #expect(fetchSpy.todoIds == ["todo-2"]) adapter.dismissSheet() adapter.dismissFullScreenCover() @@ -179,6 +189,7 @@ private protocol TodoDetailTestAdapter { var fullScreenCover: TodoDetailFeature.FullScreenCoverState? { get } func onAppear() + func onSheetTodoAppear() func setSheet(_ sheet: TodoDetailFeature.SheetState?) func dismissSheet() func setFullScreenCover(_ cover: TodoDetailFeature.FullScreenCoverState?) @@ -246,6 +257,8 @@ private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter { store.send(.onAppear) } + func onSheetTodoAppear() { store.send(.sheet(.presented(.todo(.onAppear)))) } + func setSheet(_ sheet: TodoDetailFeature.SheetState?) { store.send(.setSheet(sheet)) } From e3c80b540f4a5c6d109c14722b7633cfec65f3f7 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:25:51 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20TodoDetail=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20enum=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Detail/TodoDetailFeature.swift | 29 ++++----- .../Sources/Home/Detail/TodoDetailView.swift | 2 +- .../Tests/Home/TodoDetailFeatureTests.swift | 62 ++++++++----------- 3 files changed, 38 insertions(+), 55 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift index 753776a7..c7238e68 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift @@ -24,34 +24,27 @@ struct TodoDetailFeature { } @ObservableState - struct SheetState: Equatable { - var destination: Destination + @CasePathable + enum SheetState: Equatable { + case info + case todo(TodoDetailFeature.State) var todoDetail: TodoDetailFeature.State? { get { - guard case .todo(let state) = destination else { return nil } + guard case .todo(let state) = self else { return nil } return state } set { guard let newValue else { return } - destination = .todo(newValue) + self = .todo(newValue) } } - enum Destination: Equatable { - case info - case todo(TodoDetailFeature.State) - } - - static let info = Self(destination: .info) - static func todo(_ todoId: TodoIdItem) -> Self { - Self( - destination: .todo( - TodoDetailFeature.State( - todoId: todoId.id, - showEditButton: false - ) + .todo( + TodoDetailFeature.State( + todoId: todoId.id, + showEditButton: false ) ) } @@ -139,7 +132,7 @@ private struct TodoDetailSheetFeature: Reducer { var body: some ReducerOf { EmptyReducer() - .ifLet(\.todoDetail, action: \.todo) { + .ifCaseLet(\.todo, action: \.todo) { TodoDetailFeature() } } diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index b9c96497..05625f69 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -111,7 +111,7 @@ struct TodoDetailView: View { private func sheetContent( _ sheetStore: Store ) -> some View { - switch sheetStore.destination { + switch sheetStore.state { case .info: if let todo = store.todo { TodoDetailInfoSheetView(todo: todo) { diff --git a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift index d4da87b3..bc94a1cf 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift @@ -141,10 +141,14 @@ struct TodoDetailFeatureTests { @Test("시트와 편집 화면 상태를 액션에 맞게 변경한다") func 시트와_편집_화면_상태를_액션에_맞게_변경한다() async { - let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo(id: "todo-2")) + let reference = makeTodoReference(id: "todo-7", title: "Reference 7") + let fetchSpy = FetchTodoByIdUseCaseSpy( + todo: makeTodo(id: "todo-2", content: "- refs #7") + ) + let referenceSpy = FetchReferenceItemsUseCaseSpy(references: [7: reference]) let adapter = TodoDetailStoreTestAdapter( fetchUseCase: fetchSpy, - referenceUseCase: FetchReferenceItemsUseCaseSpy(), + referenceUseCase: referenceSpy, todoId: "todo-1", showEditButton: false ) @@ -158,16 +162,18 @@ struct TodoDetailFeatureTests { adapter.setSheet(.todo(TodoIdItem(id: "todo-2"))) - #expect(adapter.sheet?.todoDetail?.todoId == "todo-2") - #expect(adapter.sheet?.todoDetail?.showEditButton == false) + #expect(todoDetailState(in: adapter.sheet)?.todoId == "todo-2") + #expect(todoDetailState(in: adapter.sheet)?.showEditButton == false) adapter.onSheetTodoAppear() await waitUntil { - adapter.sheet?.todoDetail?.todo?.id == "todo-2" + todoDetailState(in: adapter.sheet)?.referenceItems[7] == TodoReferenceItem(from: reference) } #expect(fetchSpy.todoIds == ["todo-2"]) + #expect(referenceSpy.numbers == [[7]]) + #expect(adapter.referenceItems.isEmpty) adapter.dismissSheet() adapter.dismissFullScreenCover() @@ -202,37 +208,14 @@ private protocol TodoDetailTestAdapter { private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter { private let store: StoreOf - var todoId: String { - store.todoId - } - - var showEditButton: Bool { - store.showEditButton - } - - var todo: Todo? { - store.todo - } - - var referenceItems: [Int: TodoReferenceItem] { - store.referenceItems - } - - var isLoading: Bool { - store.isLoading - } - - var alert: AlertState? { - store.alert - } - - var sheet: TodoDetailFeature.SheetState? { - store.sheet - } - - var fullScreenCover: TodoDetailFeature.FullScreenCoverState? { - store.fullScreenCover - } + var todoId: String { store.todoId } + var showEditButton: Bool { store.showEditButton } + var todo: Todo? { store.todo } + var referenceItems: [Int: TodoReferenceItem] { store.referenceItems } + var isLoading: Bool { store.isLoading } + var alert: AlertState? { store.alert } + var sheet: TodoDetailFeature.SheetState? { store.sheet } + var fullScreenCover: TodoDetailFeature.FullScreenCoverState? { store.fullScreenCover } init( fetchUseCase: FetchTodoByIdUseCase, @@ -284,6 +267,13 @@ private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter { } } +private func todoDetailState( + in sheet: TodoDetailFeature.SheetState? +) -> TodoDetailFeature.State? { + guard case .todo(let state) = sheet else { return nil } + return state +} + private func expectedErrorAlert() -> AlertState { AlertState { TextState(String(localized: "common_error_title")) From fcd4729c41bed314cef1fa5b6d3041b3494ca2eb Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:20:01 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20Bindable=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Detail/TodoDetailView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 05625f69..66156443 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -14,11 +14,7 @@ struct TodoDetailView: View { @Environment(\.diContainer) private var container: DIContainer @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @State private var store: StoreOf - - init(store: StoreOf) { - self._store = State(initialValue: store) - } + @Bindable var store: StoreOf var body: some View { ZStack { From 41fae5f8aca558f265581f651244fbddc55eff42 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:20:35 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=ED=8F=B4=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/{CategoryManage => Category}/CategoryManageFeature.swift | 0 .../Home/{CategoryManage => Category}/CategoryManageView.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Application/DevLogPresentation/Sources/Home/{CategoryManage => Category}/CategoryManageFeature.swift (100%) rename Application/DevLogPresentation/Sources/Home/{CategoryManage => Category}/CategoryManageView.swift (100%) diff --git a/Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageFeature.swift b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageFeature.swift rename to Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift diff --git a/Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageView.swift b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageView.swift rename to Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift From 4cad673b16af49d656166274896ec75a23c10388 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:41:34 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20TodoDetail=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EB=A5=BC=20LoadingFeature=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/LoadingFeature.swift | 168 ++++++++++++++++++ .../Home/Detail/TodoDetailFeature.swift | 30 ++-- .../Tests/Common/LoadingFeatureTests.swift | 66 +++++++ .../Tests/Home/TodoDetailFeatureTests.swift | 19 +- 4 files changed, 266 insertions(+), 17 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Common/LoadingFeature.swift create mode 100644 Application/DevLogPresentation/Tests/Common/LoadingFeatureTests.swift diff --git a/Application/DevLogPresentation/Sources/Common/LoadingFeature.swift b/Application/DevLogPresentation/Sources/Common/LoadingFeature.swift new file mode 100644 index 00000000..f0e893ce --- /dev/null +++ b/Application/DevLogPresentation/Sources/Common/LoadingFeature.swift @@ -0,0 +1,168 @@ +// +// LoadingFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/11/26. +// + +import ComposableArchitecture + +@Reducer +struct LoadingFeature { + @ObservableState + struct State: Equatable { + var isLoading = false + var immediateCountByTarget: [Target: Int] = [:] + var delayedCountByTarget: [Target: Int] = [:] + var scheduledDelayedTargets = Set() + var visibleDelayedTargets = Set() + var visibleTargets = Set() + } + + struct Target: Hashable, Sendable { + static let `default` = Self("default") + + let id: String + + init(_ id: String) { + self.id = id + } + } + + enum Mode: Equatable, Sendable { + case immediate + case delayed + } + + enum Action: Equatable { + case begin(target: Target, mode: Mode) + case end(target: Target, mode: Mode) + case delayedLoadingDidBecomeVisible(target: Target) + } + + private enum CancelID: Hashable { + case delayedLoading(Target) + } + + @Dependency(\.continuousClock) var clock + private let delay = Duration.seconds(0.3) + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .begin(let target, let mode): + return begin(target: target, mode: mode, state: &state) + case .end(let target, let mode): + return end(target: target, mode: mode, state: &state) + case .delayedLoadingDidBecomeVisible(let target): + return delayedLoadingDidBecomeVisible(target: target, state: &state) + } + } + } +} + +private extension LoadingFeature { + func begin( + target: Target, + mode: Mode, + state: inout State + ) -> Effect { + switch mode { + case .immediate: + state.immediateCountByTarget[target, default: 0] += 1 + state.setVisibilityIfNeeded(for: target, isVisible: true) + return .none + case .delayed: + state.delayedCountByTarget[target, default: 0] += 1 + return scheduleDelayedLoadingIfNeeded(for: target, state: &state) + } + } + + func end( + target: Target, + mode: Mode, + state: inout State + ) -> Effect { + switch mode { + case .immediate: + let count = state.immediateCountByTarget[target, default: 0] + state.immediateCountByTarget[target] = max(0, count - 1) + case .delayed: + let count = state.delayedCountByTarget[target, default: 0] + state.delayedCountByTarget[target] = max(0, count - 1) + } + return updateLoadingVisibility(for: target, state: &state) + } + + func delayedLoadingDidBecomeVisible( + target: Target, + state: inout State + ) -> Effect { + state.scheduledDelayedTargets.remove(target) + guard 0 < state.delayedCountByTarget[target, default: 0] else { return .none } + state.visibleDelayedTargets.insert(target) + if state.immediateCountByTarget[target, default: 0] == 0 { + state.setVisibilityIfNeeded(for: target, isVisible: true) + } + return .none + } + + func scheduleDelayedLoadingIfNeeded( + for target: Target, + state: inout State + ) -> Effect { + guard !state.scheduledDelayedTargets.contains(target), + !state.visibleDelayedTargets.contains(target), + 0 < state.delayedCountByTarget[target, default: 0] else { return .none } + state.scheduledDelayedTargets.insert(target) + return .run { [clock, delay] send in + try await clock.sleep(for: delay) + await send(.delayedLoadingDidBecomeVisible(target: target)) + } + .cancellable(id: CancelID.delayedLoading(target), cancelInFlight: true) + } + + func updateLoadingVisibility( + for target: Target, + state: inout State + ) -> Effect { + if 0 < state.immediateCountByTarget[target, default: 0] { + state.setVisibilityIfNeeded(for: target, isVisible: true) + return .none + } + if state.visibleDelayedTargets.contains(target) { + if state.delayedCountByTarget[target, default: 0] == 0 { + state.visibleDelayedTargets.remove(target) + state.setVisibilityIfNeeded(for: target, isVisible: false) + } else { + state.setVisibilityIfNeeded(for: target, isVisible: true) + } + return .none + } + if 0 < state.delayedCountByTarget[target, default: 0] { + state.setVisibilityIfNeeded( + for: target, + isVisible: state.visibleTargets.contains(target) + ) + return scheduleDelayedLoadingIfNeeded(for: target, state: &state) + } + state.scheduledDelayedTargets.remove(target) + state.setVisibilityIfNeeded(for: target, isVisible: false) + return .cancel(id: CancelID.delayedLoading(target)) + } +} + +private extension LoadingFeature.State { + mutating func setVisibilityIfNeeded( + for target: LoadingFeature.Target, + isVisible: Bool + ) { + if isVisible { + visibleTargets.insert(target) + } else { + visibleTargets.remove(target) + } + + isLoading = !visibleTargets.isEmpty + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift index c7238e68..5f3ea046 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift @@ -20,7 +20,11 @@ struct TodoDetailFeature { var showEditButton: Bool var todo: Todo? var referenceItems: [Int: TodoReferenceItem] = [:] - var isLoading = false + var loading = LoadingFeature.State() + + var isLoading: Bool { + loading.isLoading + } } @ObservableState @@ -71,7 +75,7 @@ struct TodoDetailFeature { case setFullScreenCover(FullScreenCoverState?) case setTodo(Todo) case setReferenceItems([Int: TodoReferenceItem]) - case setLoading(Bool) + case loading(LoadingFeature.Action) @CasePathable enum Sheet { @@ -82,9 +86,11 @@ struct TodoDetailFeature { @Dependency(\.fetchTodoByIdUseCase) var fetchTodoUseCase @Dependency(\.fetchReferenceItemsUseCase) var fetchReferenceItemsUseCase - private let loadingState = LoadingState() var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } Reduce { state, action in switch action { case .sheet(.dismiss): @@ -113,8 +119,8 @@ struct TodoDetailFeature { return resolveMarkdownEffect(content: todo.content) case .setReferenceItems(let items): state.referenceItems = items - case .setLoading(let value): - state.isLoading = value + case .loading: + break } return .none @@ -172,20 +178,14 @@ private enum FetchReferenceItemsUseCaseKey: DependencyKey { private extension TodoDetailFeature { func fetchTodoEffect(todoId: String) -> Effect { - .run { [fetchTodoUseCase, loadingState] send in - await loadingState.begin(mode: .delayed) { isLoading in - send(.setLoading(isLoading)) - } + .run { [fetchTodoUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) do { let todo = try await fetchTodoUseCase.execute(todoId) - await loadingState.end(mode: .delayed) { isLoading in - send(.setLoading(isLoading)) - } + await send(.loading(.end(target: .default, mode: .delayed))) await send(.setTodo(todo)) } catch { - await loadingState.end(mode: .delayed) { isLoading in - send(.setLoading(isLoading)) - } + await send(.loading(.end(target: .default, mode: .delayed))) await send(.fetchFailed) } } diff --git a/Application/DevLogPresentation/Tests/Common/LoadingFeatureTests.swift b/Application/DevLogPresentation/Tests/Common/LoadingFeatureTests.swift new file mode 100644 index 00000000..88441426 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Common/LoadingFeatureTests.swift @@ -0,0 +1,66 @@ +// +// LoadingFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/11/26. +// + +import Testing +import ComposableArchitecture +@testable import DevLogPresentation + +@MainActor +struct LoadingFeatureTests { + @Test("즉시 로딩은 시작하면 표시되고 종료하면 해제된다") + func 즉시_로딩은_시작하면_표시되고_종료하면_해제된다() async { + let target = LoadingFeature.Target.default + let store = TestStore(initialState: LoadingFeature.State()) { + LoadingFeature() + } + + await store.send(.begin(target: target, mode: .immediate)) { + $0.immediateCountByTarget[target] = 1 + $0.visibleTargets = [target] + $0.isLoading = true + } + + await store.send(.end(target: target, mode: .immediate)) { + $0.immediateCountByTarget[target] = 0 + $0.visibleTargets = [] + $0.isLoading = false + } + } + + @Test("지연 로딩은 delay가 지나기 전까지 표시되지 않는다") + func 지연_로딩은_delay가_지나기_전까지_표시되지_않는다() async { + let target = LoadingFeature.Target.default + let clock = TestClock() + let store = TestStore(initialState: LoadingFeature.State()) { + LoadingFeature() + } withDependencies: { + $0.continuousClock = clock + } + + await store.send(.begin(target: target, mode: .delayed)) { + $0.delayedCountByTarget[target] = 1 + $0.scheduledDelayedTargets = [target] + } + + await clock.advance(by: .milliseconds(299)) + + await clock.advance(by: .milliseconds(1)) + await store.receive(.delayedLoadingDidBecomeVisible(target: target)) { + $0.scheduledDelayedTargets = [] + $0.visibleDelayedTargets = [target] + $0.visibleTargets = [target] + $0.isLoading = true + } + + await store.send(.end(target: target, mode: .delayed)) { + $0.delayedCountByTarget[target] = 0 + $0.visibleDelayedTargets = [] + $0.visibleTargets = [] + $0.isLoading = false + } + } +} diff --git a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift index bc94a1cf..1b113894 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift @@ -94,16 +94,28 @@ struct TodoDetailFeatureTests { @Test("Todo 조회가 지연되면 로딩 상태를 표시하고 완료되면 해제한다") func Todo_조회가_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async { + let clock = TestClock() let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo()) fetchSpy.shouldSuspend = true let adapter = TodoDetailStoreTestAdapter( fetchUseCase: fetchSpy, referenceUseCase: FetchReferenceItemsUseCaseSpy(), - todoId: "todo-1" + todoId: "todo-1", + configureDependencies: { + $0.continuousClock = clock + } ) adapter.onAppear() + await waitUntil { + fetchSpy.todoIds == ["todo-1"] + } + + #expect(!adapter.isLoading) + + await clock.advance(by: .milliseconds(300)) + await waitUntil { adapter.isLoading } @@ -221,7 +233,8 @@ private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter { fetchUseCase: FetchTodoByIdUseCase, referenceUseCase: FetchReferenceItemsUseCase, todoId: String, - showEditButton: Bool = true + showEditButton: Bool = true, + configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { store = Store( initialState: TodoDetailFeature.State( @@ -233,6 +246,8 @@ private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter { } withDependencies: { $0.fetchTodoByIdUseCase = fetchUseCase $0.fetchReferenceItemsUseCase = referenceUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) } }