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..a3fcbd1 100644 --- a/DevLog/Presentation/ViewModel/TodoViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoViewModel.swift @@ -14,10 +14,14 @@ 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)? } enum FilterOption { @@ -25,126 +29,208 @@ final class TodoViewModel: Store { } enum Action { - // Modifier - case onAppear, refresh - // 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 - // Binding - case openEditor - case closeEditor - case closeAlert + // View + case confirmDelete + case onAppear case setScope(TodoScope) case setSearchText(String) + case setToast(isPresented: Bool) + case upsertTodo(Todo) - // Call from run - case didFetchTodos([Todo]) - case didLoading(Bool) - case didShowAlert(String) + // Run case didTogglePinned(Todo) + case setLoading(Bool) + case setTodos([Todo]) } 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 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) } func reduce(with action: Action) -> [SideEffect] { + var state = self.state + var effects: [SideEffect] = [] + switch action { - case .onAppear, .refresh: - return [.fetchTodos] - case .tapTogglePinned(let todo): - return [.togglePinned(todo)] - case .swipeTodo(let todo): - return [.swipeTodo(todo)] - case .tapFilterOption(let option): - state.filterOption = option - case .upsertTodo(let todo): - return [.upsertTodo(todo)] - 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 .didFetchTodos(let todos): - state.todos = todos - case .didLoading(let value): - state.isLoading = value - 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) } - return [] + + self.state = state + return effects } 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)) + send(.setAlert(true)) } } - 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)) + send(.setAlert(true)) } } - 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)) } catch { - send(.didShowAlert(error.localizedDescription)) + send(.setAlert(true)) } } + case .delete(let item): + Task { + do { + try await deleteTodoUseCase.execute(item.id) + } catch { + 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 + ) { + 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/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) diff --git a/DevLog/UI/Home/TodoView.swift b/DevLog/UI/Home/TodoView.swift index cacddaa..ad40b63 100644 --- a/DevLog/UI/Home/TodoView.swift +++ b/DevLog/UI/Home/TodoView.swift @@ -80,24 +80,33 @@ 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) } + .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( 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)) } @@ -172,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) { } } }