From 32ef595b2cd6981dd650108be00b9535d780f59d Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 12 Feb 2026 11:17:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DomainAssembler.swift | 4 ++++ .../Todo/Delete/DeleteTodoUseCase.swift | 10 ++++++++++ .../Todo/Delete/DeleteTodoUseCaseImpl.swift | 18 ++++++++++++++++++ .../Presentation/ViewModel/TodoViewModel.swift | 14 +++++++++++++- DevLog/UI/Home/HomeView.swift | 1 + 5 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCase.swift create mode 100644 DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCaseImpl.swift diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index f99f662..d2da148 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -26,6 +26,10 @@ final class DomainAssembler: Assembler { container.register(UpsertTodoUseCase.self) { UpsertTodoUseCaseImpl(container.resolve(TodoRepository.self)) } + + container.register(DeleteTodoUseCase.self) { + DeleteTodoUseCaseImpl(container.resolve(TodoRepository.self)) + } container.register(AuthSessionUseCase.self) { AuthSessionUseCaseImpl(container.resolve(AuthSessionRepository.self)) diff --git a/DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCase.swift b/DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCase.swift new file mode 100644 index 0000000..39d7403 --- /dev/null +++ b/DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCase.swift @@ -0,0 +1,10 @@ +// +// DeleteTodoUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/12/26. +// + +protocol DeleteTodoUseCase { + func execute(_ todoID: String) async throws +} diff --git a/DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCaseImpl.swift b/DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCaseImpl.swift new file mode 100644 index 0000000..8f7419f --- /dev/null +++ b/DevLog/Domain/UseCase/Todo/Delete/DeleteTodoUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// DeleteTodoUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/12/26. +// + +final class DeleteTodoUseCaseImpl: DeleteTodoUseCase { + private let repository: TodoRepository + + init(_ repository: TodoRepository) { + self.repository = repository + } + + func execute(_ todoID: String) async throws { + try await repository.deleteTodo(todoID) + } +} diff --git a/DevLog/Presentation/ViewModel/TodoViewModel.swift b/DevLog/Presentation/ViewModel/TodoViewModel.swift index cad300c..7577ae0 100644 --- a/DevLog/Presentation/ViewModel/TodoViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoViewModel.swift @@ -57,15 +57,18 @@ final class TodoViewModel: Store { private let fetchTodosByKindUseCase: FetchTodosByKindUseCase private let upsertTodoUseCase: UpsertTodoUseCase + private let deleteTodoUseCase: DeleteTodoUseCase @Published private(set) var state: State init( fetchTodosByKindUseCase: FetchTodosByKindUseCase, upsertTodoUseCase: UpsertTodoUseCase, + deleteTodoUseCase: DeleteTodoUseCase, kind: TodoKind ) { self.fetchTodosByKindUseCase = fetchTodosByKindUseCase self.upsertTodoUseCase = upsertTodoUseCase + self.deleteTodoUseCase = deleteTodoUseCase self.state = State(kind: kind) } @@ -144,7 +147,16 @@ final class TodoViewModel: Store { } } case .swipeTodo(let todo): - break + Task { + do { + defer { send(.didLoading(false)) } + send(.didLoading(true)) + try await deleteTodoUseCase.execute(todo.id) + send(.refresh) + } catch { + send(.didShowAlert(error.localizedDescription)) + } + } } } } diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index b5b9032..a68e5b9 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -123,6 +123,7 @@ struct HomeView: View { TodoView(viewModel: TodoViewModel( fetchTodosByKindUseCase: container.resolve(FetchTodosByKindUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), kind: todoKind )) .environmentObject(router) From 8b54b8222cf5570be4fc8199f64c7f688f21a48f Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 12 Feb 2026 12:06:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20TodoViewModel=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EC=B7=A8=EC=86=8C=EC=97=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=9C=20=ED=86=A0=EC=8A=A4=ED=8A=B8=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 --- .../ViewModel/TodoViewModel.swift | 106 ++++++++++++------ DevLog/UI/Home/TodoView.swift | 11 ++ 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoViewModel.swift b/DevLog/Presentation/ViewModel/TodoViewModel.swift index 7577ae0..d215360 100644 --- a/DevLog/Presentation/ViewModel/TodoViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoViewModel.swift @@ -18,6 +18,9 @@ final class TodoViewModel: Store { var scope: TodoScope = .title var filterOption: FilterOption = .create var isLoading = false + var showToast: Bool = false + var toastMessage: String = "" + var pendingTask: (Todo, Int)? } enum FilterOption { @@ -25,34 +28,39 @@ final class TodoViewModel: Store { } enum Action { - // Modifier - case onAppear, refresh - // User case tapTogglePinned(Todo) case swipeTodo(Todo) case tapFilterOption(FilterOption) case upsertTodo(Todo) + case undoDelete + case confirmDelete - // Binding + // View + case onAppear, refresh case openEditor case closeEditor case closeAlert case setScope(TodoScope) case setSearchText(String) + case setToast(isPresented: Bool, type: ToastType? = nil) + case setLoading(Bool) + case setTodos([Todo]) - // Call from run - case didFetchTodos([Todo]) - case didLoading(Bool) + // Run case didShowAlert(String) case didTogglePinned(Todo) } + + enum ToastType { + case delete + } enum SideEffect { - case fetchTodos - case upsertTodo(Todo) + case fetch + case upsert(Todo) + case delete(Todo) case togglePinned(Todo) - case swipeTodo(Todo) } private let fetchTodosByKindUseCase: FetchTodosByKindUseCase @@ -73,17 +81,33 @@ final class TodoViewModel: Store { } func reduce(with action: Action) -> [SideEffect] { + var state = self.state + switch action { case .onAppear, .refresh: - return [.fetchTodos] + return [.fetch] case .tapTogglePinned(let todo): return [.togglePinned(todo)] case .swipeTodo(let todo): - return [.swipeTodo(todo)] + guard let index = state.todos.firstIndex(where: { $0.id == todo.id }) else { + return [] + } + state.pendingTask = (todo, index) + state.todos.remove(at: index) + setToast(&state, isPresented: true, for: .delete) case .tapFilterOption(let option): state.filterOption = option case .upsertTodo(let todo): - return [.upsertTodo(todo)] + return [.upsert(todo)] + case .undoDelete: + guard let (todo, index) = state.pendingTask else { return [] } + state.todos.insert(todo, at: index) + state.pendingTask = nil + case .confirmDelete: + guard let (item, _) = state.pendingTask else { + return [] + } + return [.delete(item)] case .openEditor: state.showEditor = true case .closeEditor: @@ -94,10 +118,12 @@ final class TodoViewModel: Store { state.scope = scope case .setSearchText(let text): state.searchText = text - case .didFetchTodos(let todos): - state.todos = todos - case .didLoading(let value): + case .setToast(let isPresented, let type): + setToast(&state, isPresented: isPresented, for: type) + case .setLoading(let value): state.isLoading = value + case .setTodos(let todos): + state.todos = todos case .didShowAlert(let message): state.alertMessage = message state.showAlert = true @@ -106,39 +132,41 @@ final class TodoViewModel: Store { state.todos[index] = todo } } + + self.state = state return [] } func run(_ effect: SideEffect) { switch effect { - case .fetchTodos: + case .fetch: Task { do { - defer { send(.didLoading(false)) } - send(.didLoading(true)) + defer { send(.setLoading(false)) } + send(.setLoading(true)) let todos = try await fetchTodosByKindUseCase.execute(state.kind) - send(.didFetchTodos(todos)) + send(.setTodos(todos)) } catch { send(.didShowAlert(error.localizedDescription)) } } - case .upsertTodo(let todo): + case .upsert(let item): Task { do { - defer { send(.didLoading(false)) } - send(.didLoading(true)) - try await upsertTodoUseCase.execute(todo) + defer { send(.setLoading(false)) } + send(.setLoading(true)) + try await upsertTodoUseCase.execute(item) send(.refresh) } catch { send(.didShowAlert(error.localizedDescription)) } } - case .togglePinned(let todo): + case .togglePinned(let item): Task { do { - defer { send(.didLoading(false)) } - send(.didLoading(true)) - var todo = todo + defer { send(.setLoading(false)) } + send(.setLoading(true)) + var todo = item todo.isPinned.toggle() try await upsertTodoUseCase.execute(todo) send(.didTogglePinned(todo)) @@ -146,13 +174,10 @@ final class TodoViewModel: Store { send(.didShowAlert(error.localizedDescription)) } } - case .swipeTodo(let todo): + case .delete(let item): Task { do { - defer { send(.didLoading(false)) } - send(.didLoading(true)) - try await deleteTodoUseCase.execute(todo.id) - send(.refresh) + try await deleteTodoUseCase.execute(item.id) } catch { send(.didShowAlert(error.localizedDescription)) } @@ -160,3 +185,18 @@ final class TodoViewModel: Store { } } } +private extension TodoViewModel { + func setToast( + _ state: inout State, + isPresented: Bool, + for type: ToastType? + ) { + switch type { + case .delete: + state.toastMessage = "실행 취소" + case .none: + state.toastMessage = "" + } + state.showToast = isPresented + } +} diff --git a/DevLog/UI/Home/TodoView.swift b/DevLog/UI/Home/TodoView.swift index cacddaa..722329d 100644 --- a/DevLog/UI/Home/TodoView.swift +++ b/DevLog/UI/Home/TodoView.swift @@ -92,6 +92,17 @@ struct TodoView: View { } message: { Text(viewModel.state.alertMessage) } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) } + ), + duration: 5, + action: { viewModel.send(.undoDelete) }, + onDismiss: { viewModel.send(.confirmDelete) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + } .navigationTitle(viewModel.state.kind.localizedName) .navigationBarTitleDisplayMode(.large) .fullScreenCover(isPresented: Binding( From b0eeb2771b5cd6376f983e459f7fbda85fd4b15c Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 12 Feb 2026 14:13:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20TodoViewModel=EC=9D=98=20?= =?UTF-8?q?=EB=A6=B0=ED=8A=B8=20=EA=B2=BD=EA=B3=A0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20Action=20=EC=B5=9C=EC=A0=81=ED=99=94,=20=EC=96=BC?= =?UTF-8?q?=EB=9F=BF=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodoViewModel.swift | 190 +++++++++++------- DevLog/Resource/Localizable.xcstrings | 3 - DevLog/UI/Home/TodoView.swift | 24 +-- DevLog/UI/Search/SearchView.swift | 2 +- 4 files changed, 124 insertions(+), 95 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoViewModel.swift b/DevLog/Presentation/ViewModel/TodoViewModel.swift index d215360..a3fcbd1 100644 --- a/DevLog/Presentation/ViewModel/TodoViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoViewModel.swift @@ -14,10 +14,11 @@ final class TodoViewModel: Store { let kind: TodoKind var showEditor: Bool = false var showAlert: Bool = false + var alertTitle: String = "" var alertMessage: String = "" var scope: TodoScope = .title var filterOption: FilterOption = .create - var isLoading = false + var isLoading: Bool = false var showToast: Bool = false var toastMessage: String = "" var pendingTask: (Todo, Int)? @@ -29,31 +30,26 @@ final class TodoViewModel: Store { enum Action { // User - case tapTogglePinned(Todo) + case refresh + case setAlert(Bool) + case setShowEditor(Bool) case swipeTodo(Todo) case tapFilterOption(FilterOption) - case upsertTodo(Todo) + case tapTogglePinned(Todo) case undoDelete - case confirmDelete // View - case onAppear, refresh - case openEditor - case closeEditor - case closeAlert + case confirmDelete + case onAppear case setScope(TodoScope) case setSearchText(String) - case setToast(isPresented: Bool, type: ToastType? = nil) - case setLoading(Bool) - case setTodos([Todo]) + case setToast(isPresented: Bool) + case upsertTodo(Todo) // Run - case didShowAlert(String) case didTogglePinned(Todo) - } - - enum ToastType { - case delete + case setLoading(Bool) + case setTodos([Todo]) } enum SideEffect { @@ -82,59 +78,21 @@ final class TodoViewModel: Store { func reduce(with action: Action) -> [SideEffect] { var state = self.state - + var effects: [SideEffect] = [] + switch action { - case .onAppear, .refresh: - return [.fetch] - case .tapTogglePinned(let todo): - return [.togglePinned(todo)] - case .swipeTodo(let todo): - guard let index = state.todos.firstIndex(where: { $0.id == todo.id }) else { - return [] - } - state.pendingTask = (todo, index) - state.todos.remove(at: index) - setToast(&state, isPresented: true, for: .delete) - case .tapFilterOption(let option): - state.filterOption = option - case .upsertTodo(let todo): - return [.upsert(todo)] - case .undoDelete: - guard let (todo, index) = state.pendingTask else { return [] } - state.todos.insert(todo, at: index) - state.pendingTask = nil - case .confirmDelete: - guard let (item, _) = state.pendingTask else { - return [] - } - return [.delete(item)] - case .openEditor: - state.showEditor = true - case .closeEditor: - state.showEditor = false - case .closeAlert: - state.showAlert = false - case .setScope(let scope): - state.scope = scope - case .setSearchText(let text): - state.searchText = text - case .setToast(let isPresented, let type): - setToast(&state, isPresented: isPresented, for: type) - case .setLoading(let value): - state.isLoading = value - case .setTodos(let todos): - state.todos = todos - case .didShowAlert(let message): - state.alertMessage = message - state.showAlert = true - case .didTogglePinned(let todo): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - state.todos[index] = todo - } + case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapTogglePinned, .undoDelete: + effects = reduceByUser(action, state: &state) + + case .confirmDelete, .onAppear, .setScope, .setSearchText, .setToast, .upsertTodo: + effects = reduceByView(action, state: &state) + + case .didTogglePinned, .setLoading, .setTodos: + effects = reduceByRun(action, state: &state) } - + self.state = state - return [] + return effects } func run(_ effect: SideEffect) { @@ -147,7 +105,7 @@ final class TodoViewModel: Store { let todos = try await fetchTodosByKindUseCase.execute(state.kind) send(.setTodos(todos)) } catch { - send(.didShowAlert(error.localizedDescription)) + send(.setAlert(true)) } } case .upsert(let item): @@ -158,7 +116,7 @@ final class TodoViewModel: Store { try await upsertTodoUseCase.execute(item) send(.refresh) } catch { - send(.didShowAlert(error.localizedDescription)) + send(.setAlert(true)) } } case .togglePinned(let item): @@ -171,7 +129,7 @@ final class TodoViewModel: Store { try await upsertTodoUseCase.execute(todo) send(.didTogglePinned(todo)) } catch { - send(.didShowAlert(error.localizedDescription)) + send(.setAlert(true)) } } case .delete(let item): @@ -179,24 +137,100 @@ final class TodoViewModel: Store { do { try await deleteTodoUseCase.execute(item.id) } catch { - send(.didShowAlert(error.localizedDescription)) + send(.setAlert(true)) } } } } } + +// MARK: - Reduce Methods +private extension TodoViewModel { + 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): + guard let index = state.todos.firstIndex(where: { $0.id == todo.id }) else { + return [] + } + state.pendingTask = (todo, index) + state.todos.remove(at: index) + setToast(&state, isPresented: true) + case .tapFilterOption(let option): + state.filterOption = option + case .tapTogglePinned(let todo): + return [.togglePinned(todo)] + case .undoDelete: + guard let (todo, index) = state.pendingTask else { return [] } + state.todos.insert(todo, at: index) + state.pendingTask = nil + default: + break + } + return [] + } + + func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .confirmDelete: + guard let (item, _) = state.pendingTask else { + return [] + } + return [.delete(item)] + case .onAppear: + return [.fetch] + case .setScope(let scope): + state.scope = scope + case .setSearchText(let text): + state.searchText = text + case .setToast(let isPresented): + setToast(&state, isPresented: isPresented) + case .upsertTodo(let todo): + return [.upsert(todo)] + default: + break + } + return [] + } + + func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .didTogglePinned(let todo): + if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { + state.todos[index] = todo + } + case .setLoading(let value): + state.isLoading = value + case .setTodos(let todos): + state.todos = todos + default: + break + } + return [] + } +} + +// MARK: - Helper Methods private extension TodoViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } + func setToast( _ state: inout State, - isPresented: Bool, - for type: ToastType? + isPresented: Bool ) { - switch type { - case .delete: - state.toastMessage = "실행 취소" - case .none: - state.toastMessage = "" - } + state.toastMessage = "실행 취소" state.showToast = isPresented } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 8e25a0a..0688813 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -286,9 +286,6 @@ }, "베타 테스트 참여" : { - }, - "불러오기 실패" : { - }, "사용자 설정" : { diff --git a/DevLog/UI/Home/TodoView.swift b/DevLog/UI/Home/TodoView.swift index 722329d..ad40b63 100644 --- a/DevLog/UI/Home/TodoView.swift +++ b/DevLog/UI/Home/TodoView.swift @@ -80,15 +80,13 @@ struct TodoView: View { } } } - .alert("불러오기 실패", isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { _, _ in } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } )) { - Button(role: .cancel, action: { - viewModel.send(.closeAlert) - }) { - Text("확인") - } + Button("확인", role: .cancel) { } } message: { Text(viewModel.state.alertMessage) } @@ -107,8 +105,8 @@ struct TodoView: View { .navigationBarTitleDisplayMode(.large) .fullScreenCover(isPresented: Binding( get: { viewModel.state.showEditor }, - set: { _, _ in viewModel.send(.closeEditor) }) - ) { + set: { viewModel.send(.setShowEditor($0)) } + )) { TodoEditorView( viewModel: TodoEditorViewModel(kind: viewModel.state.kind), onSubmit: { viewModel.send(.upsertTodo($0)) } @@ -183,9 +181,9 @@ struct TodoView: View { }, label: { Image(systemName: "ellipsis") }) - Button(action: { - viewModel.send(.openEditor) - }) { + Button { + viewModel.send(.setShowEditor(true)) + } label: { Image(systemName: "plus") } } diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 32dc31c..bc677b5 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -114,7 +114,7 @@ struct SearchView: View { } } case .error: - Button("확인", role: .cancel) {} + Button("확인", role: .cancel) { } } }