From 383553110d8afbec02c06a499838141da08676e9 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:11:28 +0900 Subject: [PATCH 01/12] =?UTF-8?q?refactor:=20TodoEditorView=20TCA=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Detail/TodoDetailView.swift | 23 +- .../Home/Editor/TodoEditorFeature.swift | 358 ++++++++++++++++++ .../Sources/Home/Editor/TodoEditorView.swift | 127 ++++--- .../Home/Editor/TodoEditorViewModel.swift | 343 ----------------- .../Sources/Home/Home/HomeView.swift | 6 +- .../Home/Home/HomeViewCoordinator.swift | 21 +- .../Sources/Home/List/TodoListView.swift | 24 +- .../WindowGroup/TodoEditorWindowView.swift | 37 +- 8 files changed, 486 insertions(+), 453 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift delete mode 100644 Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 66156443..245c6f6c 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -88,16 +88,19 @@ struct TodoDetailView: View { 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)) - } - ) + store: Store(initialState: TodoEditorFeature.State(todo: todo)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve( + FetchTodoCategoryPreferencesUseCase.self + ) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + }, + onUpdateSuccess: { todo in + store.send(.setFullScreenCover(nil)) + store.send(.setTodo(todo)) + } ) } } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift new file mode 100644 index 00000000..14cfe026 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -0,0 +1,358 @@ +// +// TodoEditorFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation +import OrderedCollections + +@Reducer +struct TodoEditorFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var isCompleted: Bool = false + var completedAt: Date? + var isPinned: Bool = false + var selectedTodoId: TodoIdItem? + var title: String = "" + var content: String = "" + var referenceItems: [Int: TodoReferenceItem] = [:] + var dueDate: Date? + var showInfo: Bool = false + var isLoading: Bool = false + var tags: OrderedSet = [] + var tagText: String = "" + var focusOnEditor: Bool = false + var tabViewTag: Tag = .editor + var categories: [TodoCategoryItem] = [] + var category = TodoCategoryItem(from: .system(.etc)) + var saveResult: SaveResult? + var id: String + var isChecked: Bool + var number: Int? + var createdAt: Date? + var deletedAt: Date? + var originalDraft: TodoDraft? + + var isValidToSave: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + var navigationTitle: String { + if originalDraft == nil { + return String.localizedStringWithFormat( + String(localized: "todo_editor_new_format"), + category.localizedName + ) + } + + return String(localized: "todo_edit") + } + var hasChanges: Bool { + guard let originalDraft else { return true } + return originalDraft != makeTodoDraft(now: Date()) + } + var isReadyToSubmit: Bool { + isValidToSave && hasChanges + } + + init(category: TodoCategory, id: String = UUID().uuidString) { + self.id = id + self.isChecked = false + self.number = nil + self.createdAt = nil + self.deletedAt = nil + self.originalDraft = nil + self.category = TodoCategoryItem(from: category) + self.categories = [TodoCategoryItem(from: category)] + } + + init(todo: Todo) { + self.id = todo.id + self.isChecked = todo.isChecked + self.number = todo.number + self.createdAt = todo.createdAt + self.deletedAt = todo.deletedAt + self.originalDraft = TodoDraft(todo: todo) + self.isCompleted = todo.isCompleted + self.completedAt = todo.completedAt + self.isPinned = todo.isPinned + self.title = todo.title + self.content = todo.content + self.dueDate = todo.dueDate + self.tags = OrderedSet(todo.tags) + self.category = TodoCategoryItem(from: todo.category) + } + } + + enum Tag: Equatable { + case editor + case preview + } + + enum SaveResult: Equatable { + case created + case updated(Todo) + } + + enum Action { + case alert(PresentationAction) + case onAppear + case addTag(String) + case removeTag(String) + case setContent(String) + case setCompleted(Bool) + case setDueDate(Date?) + case setCategory(TodoCategoryItem) + case setAlert(Bool) + case setLoading(Bool) + case setPinned(Bool) + case setShowInfo(Bool) + case setSelectedTodoId(TodoIdItem?) + case setTabViewTag(Tag) + case setTagText(String) + case setTitle(String) + case setCategories([TodoCategoryItem]) + case setReferenceItems([Int: TodoReferenceItem]) + case upsertTodo + case createSucceeded + case updateSucceeded(Todo) + } + + @Dependency(\.date.now) var now + @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.fetchReferenceItemsUseCase) var fetchReferenceItemsUseCase + @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert: + break + case .onAppear: + return fetchCategoriesEffect() + case .addTag(let tag): + if !tag.isEmpty { + state.tags.append(tag) + } + case .removeTag(let tagText): + state.tags.removeAll { $0 == tagText } + case .setContent(let content): + state.content = content + if state.tabViewTag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .setTagText(let tagText): + state.tagText = tagText + if state.tabViewTag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .setTitle(let title): + state.title = title + if state.tabViewTag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .setDueDate(let dueDate): + if let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), + let dueDate { + state.dueDate = max(dueDate, tomorrowDate) + } else { + state.dueDate = nil + } + case .setCompleted(let isCompleted): + if state.isCompleted != isCompleted { + state.completedAt = isCompleted ? now : nil + } + state.isCompleted = isCompleted + case .setCategory(let item): + state.category = item + case .setAlert(let isPresented): + state.alert = isPresented ? Self.alertState() : nil + case .setLoading(let value): + state.isLoading = value + case .setPinned(let isPinned): + state.isPinned = isPinned + case .setShowInfo(let isPresented): + state.showInfo = isPresented + case .setSelectedTodoId(let todoId): + state.selectedTodoId = todoId + case .setTabViewTag(let tag): + state.tabViewTag = tag + if tag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .setCategories(let categories): + state.categories = categories + case .setReferenceItems(let items): + state.referenceItems = items + case .upsertTodo: + state.saveResult = nil + if state.originalDraft == nil { + return createTodoEffect(state.makeTodoDraft(now: now)) + } else if let todo = state.makeTodo(now: now) { + return updateTodoEffect(todo) + } + case .createSucceeded: + state.saveResult = .created + case .updateSucceeded(let todo): + state.saveResult = .updated(todo) + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension DependencyValues { + var fetchTodoCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase { + get { self[FetchTodoCategoryPreferencesUseCaseKey.self] } + set { self[FetchTodoCategoryPreferencesUseCaseKey.self] = newValue } + } + + var upsertTodoUseCase: UpsertTodoUseCase { + get { self[UpsertTodoUseCaseKey.self] } + set { self[UpsertTodoUseCaseKey.self] = newValue } + } + + var trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? { + get { self[TrackAnalyticsEventUseCaseKey.self] } + set { self[TrackAnalyticsEventUseCaseKey.self] = newValue } + } +} + +private enum FetchTodoCategoryPreferencesUseCaseKey: DependencyKey { + static var liveValue: FetchTodoCategoryPreferencesUseCase { + preconditionFailure("FetchTodoCategoryPreferencesUseCase must be provided.") + } + + static var testValue: FetchTodoCategoryPreferencesUseCase { + liveValue + } +} + +private enum UpsertTodoUseCaseKey: DependencyKey { + static var liveValue: UpsertTodoUseCase { + preconditionFailure("UpsertTodoUseCase must be provided.") + } + + static var testValue: UpsertTodoUseCase { + liveValue + } +} + +private enum TrackAnalyticsEventUseCaseKey: DependencyKey { + static let liveValue: TrackAnalyticsEventUseCase? = nil +} + +private extension TodoEditorFeature { + func fetchCategoriesEffect() -> Effect { + .run { [fetchPreferencesUseCase] send in + do { + let preferences = try await fetchPreferencesUseCase.execute() + await send(.setCategories(preferences.map(TodoCategoryItem.init(from:)))) + } catch { } + } + } + + 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 createTodoEffect(_ draft: TodoDraft) -> Effect { + .run { [trackAnalyticsEventUseCase, upsertTodoUseCase] send in + await send(.setLoading(true)) + do { + try await upsertTodoUseCase.execute(draft) + trackAnalyticsEventUseCase?.execute(.todoCreate) + await send(.createSucceeded) + } catch { + await send(.setAlert(true)) + } + await send(.setLoading(false)) + } + } + + func updateTodoEffect(_ todo: Todo) -> Effect { + .run { [upsertTodoUseCase] send in + await send(.setLoading(true)) + do { + try await upsertTodoUseCase.execute(todo) + await send(.updateSucceeded(todo)) + } catch { + await send(.setAlert(true)) + } + await send(.setLoading(false)) + } + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } + } +} + +private extension TodoEditorFeature.State { + func makeTodoDraft(now: Date) -> TodoDraft { + TodoDraft( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + title: title, + content: content, + createdAt: now, + updatedAt: now, + completedAt: completedAt, + dueDate: dueDate, + tags: Array(tags), + category: category.category + ) + } + + func makeTodo(now: Date) -> Todo? { + guard let number, let createdAt else { return nil } + return Todo( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + number: number, + title: title, + content: content, + createdAt: createdAt, + updatedAt: now, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: Array(tags), + category: category.category + ) + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 7a757615..b9e707a5 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -6,19 +6,20 @@ // import MarkdownUI -import OrderedCollections import SwiftUI import ComposableArchitecture import DevLogCore import DevLogDomain struct TodoEditorView: View { - @State var viewModel: TodoEditorViewModel @Environment(\.diContainer) private var container: DIContainer @Environment(\.dismiss) private var dismiss @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac + @Bindable var store: StoreOf @FocusState private var field: Field? private let calendar = Calendar.current + var onCreateSuccess: (() -> Void)? + var onUpdateSuccess: ((Todo) -> Void)? var onClose: (() -> Void)? var body: some View { @@ -45,21 +46,24 @@ struct TodoEditorView: View { .onTapGesture { field = .content } - .onAppear { viewModel.send(.onAppear) } - .navigationTitle(viewModel.navigationTitle) + .onAppear { store.send(.onAppear) } + .onChange(of: store.saveResult) { _, result in + handleSaveResult(result) + } + .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) .sheet(isPresented: Binding( - get: { viewModel.state.showInfo }, - set: { viewModel.send(.setShowInfo($0)) } + get: { store.showInfo }, + set: { store.send(.setShowInfo($0)) } )) { - TodoEditorInfoSheetView(viewModel: viewModel) { - viewModel.send(.setShowInfo(false)) + TodoEditorInfoSheetView(store: store) { + store.send(.setShowInfo(false)) } } .sheet(item: Binding( - get: { viewModel.state.selectedTodoId }, - set: { viewModel.send(.setSelectedTodoId($0)) } + get: { store.selectedTodoId }, + set: { store.send(.setSelectedTodoId($0)) } )) { item in NavigationStack { TodoDetailView(store: Store( @@ -72,7 +76,7 @@ struct TodoEditorView: View { }) .toolbar { ToolbarLeadingButton { - viewModel.send(.setSelectedTodoId(nil)) + store.send(.setSelectedTodoId(nil)) } } } @@ -85,7 +89,7 @@ struct TodoEditorView: View { } ToolbarItem(placement: .topBarTrailing) { Button { - viewModel.send(.setShowInfo(true)) + store.send(.setShowInfo(true)) } label: { Image(systemName: "info.circle") } @@ -93,19 +97,9 @@ struct TodoEditorView: View { ToolbarTrailingButton { submit() } - .disabled(!viewModel.isReadyToSubmit || viewModel.state.isLoading) - } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) + .disabled(!store.isReadyToSubmit || store.isLoading) } + .alert($store.scope(state: \.alert, action: \.alert)) } } @@ -129,8 +123,8 @@ struct TodoEditorView: View { TextField( "", text: Binding( - get: { viewModel.state.title }, - set: { viewModel.send(.setTitle($0)) } + get: { store.title }, + set: { store.send(.setTitle($0)) } ), prompt: Text(String(localized: "todo_editor_title_required")).foregroundColor(Color.secondary), ) @@ -143,10 +137,10 @@ struct TodoEditorView: View { Picker( "", selection: Binding( - get: { viewModel.state.tabViewTag }, + get: { store.tabViewTag }, set: { tag in if tag == .editor { - viewModel.send(.setTabViewTag(.editor)) + store.send(.setTabViewTag(.editor)) field = .content } else { transitionToPreview() @@ -155,35 +149,35 @@ struct TodoEditorView: View { ) ) { Text(String(localized: "todo_write")) - .tag(TodoEditorViewModel.Tag.editor) + .tag(TodoEditorFeature.Tag.editor) Text(String(localized: "todo_preview")) - .tag(TodoEditorViewModel.Tag.preview) + .tag(TodoEditorFeature.Tag.preview) } .pickerStyle(.segmented) } private var tabView: some View { Group { - if viewModel.state.tabViewTag == .editor { + if store.tabViewTag == .editor { VStack(alignment: .leading, spacing: 8) { markdownHint UIKitTextEditor( text: Binding( - get: { viewModel.state.content }, - set: { viewModel.send(.setContent($0)) } + get: { store.content }, + set: { store.send(.setContent($0)) } ), placeholder: String(localized: "todo_editor_description_optional") ) .focused($field, equals: .content) } } else { - if viewModel.state.content.isEmpty { + if store.content.isEmpty { previewPlaceholder } else { TodoMarkdownContentView( - content: viewModel.state.content, - referenceItems: viewModel.state.referenceItems, - onOpenTodoID: { viewModel.send(.setSelectedTodoId(TodoIdItem(id: $0))) } + content: store.content, + referenceItems: store.referenceItems, + onOpenTodoID: { store.send(.setSelectedTodoId(TodoIdItem(id: $0))) } ) } } @@ -211,7 +205,7 @@ struct TodoEditorView: View { } private func submit() { - viewModel.send(.upsertTodo) + store.send(.upsertTodo) } private func close() { @@ -226,7 +220,18 @@ struct TodoEditorView: View { field = nil DispatchQueue.main.async { - viewModel.send(.setTabViewTag(.preview)) + store.send(.setTabViewTag(.preview)) + } + } + + private func handleSaveResult(_ result: TodoEditorFeature.SaveResult?) { + switch result { + case .created: + onCreateSuccess?() + case .updated(let todo): + onUpdateSuccess?(todo) + case .none: + break } } @@ -236,7 +241,7 @@ struct TodoEditorView: View { } private struct TodoEditorInfoSheetView: View { - @Bindable var viewModel: TodoEditorViewModel + @Bindable var store: StoreOf let onClose: () -> Void @FocusState private var isTagFieldFocused: Bool private let calendar = Calendar.current @@ -248,19 +253,19 @@ private struct TodoEditorInfoSheetView: View { Picker( String(localized: "todo_category"), selection: Binding( - get: { viewModel.state.category.id }, + get: { store.category.id }, set: { categoryId in - guard let item = viewModel.state.categories.first(where: { + guard let item = store.categories.first(where: { $0.id == categoryId }) else { return } - viewModel.send(.setCategory(item)) + store.send(.setCategory(item)) } ) ) { - ForEach(viewModel.state.categories, id: \.id) { item in + ForEach(store.categories, id: \.id) { item in Text(item.localizedName) .tag(item.id) } @@ -269,8 +274,8 @@ private struct TodoEditorInfoSheetView: View { Toggle( String(localized: "todo_completed"), isOn: Binding( - get: { viewModel.state.isCompleted }, - set: { viewModel.send(.setCompleted($0)) } + get: { store.isCompleted }, + set: { store.send(.setCompleted($0)) } ) ) .tint(.blue) @@ -278,8 +283,8 @@ private struct TodoEditorInfoSheetView: View { Toggle( String(localized: "todo_pinned"), isOn: Binding( - get: { viewModel.state.isPinned }, - set: { viewModel.send(.setPinned($0)) } + get: { store.isPinned }, + set: { store.send(.setPinned($0)) } ) ) .tint(.blue) @@ -292,8 +297,8 @@ private struct TodoEditorInfoSheetView: View { TextField( String(localized: "todo_add"), text: Binding( - get: { viewModel.state.tagText }, - set: { viewModel.send(.setTagText($0)) } + get: { store.tagText }, + set: { store.send(.setTagText($0)) } ) ) .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) @@ -315,15 +320,15 @@ private struct TodoEditorInfoSheetView: View { } } - if viewModel.state.tags.isEmpty { + if store.tags.isEmpty { Text(String(localized: "todo_no_tags")) .foregroundStyle(.secondary) .padding(.vertical, 4) } else { TagList( - viewModel.state.tags, + store.tags, isEditing: isTagFieldFocused, - action: { viewModel.send(.removeTag($0)) } + action: { store.send(.removeTag($0)) } ) } } @@ -340,16 +345,16 @@ private struct TodoEditorInfoSheetView: View { private var dueDateControl: some View { DueDatePicker(selection: Binding( - get: { viewModel.state.dueDate ?? Date() }, - set: { viewModel.send(.setDueDate($0)) } + get: { store.dueDate ?? Date() }, + set: { store.send(.setDueDate($0)) } )) { HStack { Text(String(localized: "todo_due_date")) .foregroundStyle(.primary) Spacer() - if let dueDate = viewModel.state.dueDate { + if let dueDate = store.dueDate { Tag(dueDateText(for: dueDate), isEditing: true) { - viewModel.send(.setDueDate(nil)) + store.send(.setDueDate(nil)) } .padding(.vertical, -4) } else { @@ -364,16 +369,16 @@ private struct TodoEditorInfoSheetView: View { guard canSubmitTag else { return } let tagText = normalizedTagText - viewModel.send(.addTag(tagText)) - viewModel.send(.setTagText("")) + store.send(.addTag(tagText)) + store.send(.setTagText("")) } private var normalizedTagText: String { - viewModel.state.tagText.trimmingCharacters(in: .whitespacesAndNewlines) + store.tagText.trimmingCharacters(in: .whitespacesAndNewlines) } private var canSubmitTag: Bool { - !normalizedTagText.isEmpty && !viewModel.state.tags.contains(normalizedTagText) + !normalizedTagText.isEmpty && !store.tags.contains(normalizedTagText) } private func dueDateText(for dueDate: Date) -> String { diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift deleted file mode 100644 index 0919804f..00000000 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift +++ /dev/null @@ -1,343 +0,0 @@ -// -// TodoEditorViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/24/25. -// - -import Foundation -import OrderedCollections -import DevLogDomain - -@Observable -final class TodoEditorViewModel: StorePattern { - struct State: Equatable { - var isCompleted: Bool = false - var completedAt: Date? - var isPinned: Bool = false - var selectedTodoId: TodoIdItem? - var title: String = "" - var content: String = "" - var referenceItems: [Int: TodoReferenceItem] = [:] - var dueDate: Date? - var showInfo: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var isLoading: Bool = false - var tags: OrderedSet = [] - var tagText: String = "" - var focusOnEditor: Bool = false - var tabViewTag: Tag = .editor - var categories: [TodoCategoryItem] = [] - var category = TodoCategoryItem(from: .system(.etc)) - var isValidToSave: Bool { - !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - } - - enum Tag { - case editor, preview - } - - enum Action { - case onAppear - case addTag(String) - case removeTag(String) - case setContent(String) - case setCompleted(Bool) - case setDueDate(Date?) - case setCategory(TodoCategoryItem) - case setAlert(Bool) - case setLoading(Bool) - case setPinned(Bool) - case setShowInfo(Bool) - case setSelectedTodoId(TodoIdItem?) - case setTabViewTag(Tag) - case setTagText(String) - case setTitle(String) - case setCategories([TodoCategoryItem]) - case setReferenceItems([Int: TodoReferenceItem]) - case upsertTodo - } - - enum SideEffect { - case fetchCategories - case resolveMarkdown(String) - case createTodo(TodoDraft) - case updateTodo(Todo) - } - - private(set) var state = State() - private let calendar = Calendar.current - private let fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase - private let fetchReferenceItemsUseCase: FetchReferenceItemsUseCase - private let upsertTodoUseCase: UpsertTodoUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? - private let onCreateSuccess: (() -> Void)? - private let onUpdateSuccess: ((Todo) -> Void)? - private let id: String - private let isChecked: Bool - private let number: Int? - private let createdAt: Date? - private let deletedAt: Date? - private let originalDraft: TodoDraft? - - var navigationTitle: String { - if originalDraft == nil { - return String.localizedStringWithFormat( - String(localized: "todo_editor_new_format"), - state.category.localizedName - ) - } - - return String(localized: "todo_edit") - } - - var hasChanges: Bool { - guard let originalDraft else { return true } - return originalDraft != makeTodoDraft() - } - - var isReadyToSubmit: Bool { - state.isValidToSave && hasChanges - } - - // 새로운 Todo 생성용 생성자 - init( - category: TodoCategory, - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - upsertTodoUseCase: UpsertTodoUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil, - onCreateSuccess: (() -> Void)? = nil - ) { - self.fetchPreferencesUseCase = fetchPreferencesUseCase - self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - self.upsertTodoUseCase = upsertTodoUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.onCreateSuccess = onCreateSuccess - self.onUpdateSuccess = nil - self.id = UUID().uuidString - self.isChecked = false - self.number = nil - self.createdAt = nil - self.deletedAt = nil - self.originalDraft = nil - state.category = TodoCategoryItem(from: category) - state.categories = [TodoCategoryItem(from: category)] - } - - // 기존 Todo 편집용 생성자 - init( - todo: Todo, - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - upsertTodoUseCase: UpsertTodoUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil, - onUpdateSuccess: ((Todo) -> Void)? = nil - ) { - self.fetchPreferencesUseCase = fetchPreferencesUseCase - self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - self.upsertTodoUseCase = upsertTodoUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.onCreateSuccess = nil - self.onUpdateSuccess = onUpdateSuccess - self.id = todo.id - self.isChecked = todo.isChecked - self.number = todo.number - self.createdAt = todo.createdAt - self.deletedAt = todo.deletedAt - self.originalDraft = TodoDraft(todo: todo) - state.isCompleted = todo.isCompleted - state.completedAt = todo.completedAt - state.isPinned = todo.isPinned - state.title = todo.title - state.content = todo.content - state.dueDate = todo.dueDate - state.tags = OrderedSet(todo.tags) - state.category = TodoCategoryItem(from: todo.category) - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .onAppear: - effects = [.fetchCategories] - case .addTag(let tag): - if !tag.isEmpty { - state.tags.append(tag) - } - case .removeTag(let tagText): - state.tags.removeAll { $0 == tagText } - case .setContent(let stringValue), - .setTagText(let stringValue), - .setTitle(let stringValue): - handleStringAction(action, stringValue: stringValue, state: &state) - if state.tabViewTag == .preview { - effects = [.resolveMarkdown(state.content)] - } - case .setDueDate(let dueDate): - if let tomorrowDate = calendar.date(byAdding: .day, value: 1, to: Date()), let dueDate { - state.dueDate = max(dueDate, tomorrowDate) - } else { - state.dueDate = nil - } - case .setCompleted(let isCompleted): - if state.isCompleted != isCompleted { - state.completedAt = isCompleted ? Date() : nil - } - state.isCompleted = isCompleted - case .setCategory(let todoCategoryItem): - state.category = todoCategoryItem - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .setLoading(let value): - state.isLoading = value - case .setPinned(let isPinned): - state.isPinned = isPinned - case .setShowInfo(let isPresented): - state.showInfo = isPresented - case .setSelectedTodoId(let todoId): - state.selectedTodoId = todoId - case .setTabViewTag(let tag): - state.tabViewTag = tag - if tag == .preview { - effects = [.resolveMarkdown(state.content)] - } - case .setCategories(let categories): - state.categories = categories - case .setReferenceItems(let items): - state.referenceItems = items - case .upsertTodo: - if originalDraft == nil { - effects = [.createTodo(makeTodoDraft())] - } else if let todo = makeTodo() { - effects = [.updateTodo(todo)] - } - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchCategories: - Task { - do { - let preferences = try await fetchPreferencesUseCase.execute() - send(.setCategories(preferences.map(TodoCategoryItem.init(from:)))) - } catch { } - } - 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)) - } - case .createTodo(let todoDraft): - send(.setLoading(true)) - Task { - do { - defer { send(.setLoading(false)) } - try await upsertTodoUseCase.execute(todoDraft) - trackAnalyticsEventUseCase?.execute(.todoCreate) - onCreateSuccess?() - } catch { - send(.setAlert(true)) - } - } - case .updateTodo(let todo): - send(.setLoading(true)) - Task { - do { - defer { send(.setLoading(false)) } - try await upsertTodoUseCase.execute(todo) - onUpdateSuccess?(todo) - } catch { - send(.setAlert(true)) - } - } - } - } -} - -extension TodoEditorViewModel { - private func handleStringAction( - _ action: Action, - stringValue: String, - state: inout State - ) { - switch action { - case .setContent: - state.content = stringValue - case .setTagText: - state.tagText = stringValue - case .setTitle: - state.title = stringValue - default: - break - } - } - - private 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 makeTodoDraft() -> TodoDraft { - let date = Date() - return TodoDraft( - id: self.id, - isPinned: state.isPinned, - isCompleted: state.isCompleted, - isChecked: self.isChecked, - title: state.title, - content: state.content, - createdAt: date, - updatedAt: date, - completedAt: state.completedAt, - dueDate: state.dueDate, - tags: Array(state.tags), - category: state.category.category - ) - } - - private func makeTodo() -> Todo? { - guard let number, let createdAt else { return nil } - let date = Date() - return Todo( - id: self.id, - isPinned: state.isPinned, - isCompleted: state.isCompleted, - isChecked: self.isChecked, - number: number, - title: state.title, - content: state.content, - createdAt: createdAt, - updatedAt: date, - completedAt: state.completedAt, - deletedAt: self.deletedAt, - dueDate: state.dueDate, - tags: Array(state.tags), - category: state.category.category - ) - } -} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index c3a073a9..82242d23 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -50,7 +50,11 @@ struct HomeView: View { )) { if let selectedCategory = coordinator.viewModel.state.selectedTodoCategory { TodoEditorView( - viewModel: coordinator.makeTodoEditorViewModel(category: selectedCategory) + store: coordinator.makeTodoEditorStore(category: selectedCategory), + onCreateSuccess: { + coordinator.viewModel.send(.setPresentation(.todoEditor, false)) + coordinator.viewModel.send(.fetchData) + } ) } } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 1bd6c929..19e5ba68 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -77,18 +77,15 @@ final class HomeViewCoordinator { .store(in: &cancellables) } - func makeTodoEditorViewModel(category: TodoCategory) -> TodoEditorViewModel { - TodoEditorViewModel( - category: category, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - onCreateSuccess: { [weak self] in - self?.viewModel.send(.setPresentation(.todoEditor, false)) - self?.viewModel.send(.fetchData) - } - ) + func makeTodoEditorStore(category: TodoCategory) -> StoreOf { + Store(initialState: TodoEditorFeature.State(category: category)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = self.container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.fetchReferenceItemsUseCase = self.container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = self.container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = self.container.resolve(TrackAnalyticsEventUseCase.self) + } } func makeSearchStore() -> StoreOf { diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index 809677ed..f5759749 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -71,17 +72,18 @@ struct TodoListView: View { set: { viewModel.send(.setShowEditor($0)) } )) { TodoEditorView( - viewModel: TodoEditorViewModel( - category: viewModel.category, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - onCreateSuccess: { - viewModel.send(.setShowEditor(false)) - viewModel.send(.refresh) - } - ) + store: Store(initialState: TodoEditorFeature.State(category: viewModel.category)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + }, + onCreateSuccess: { + viewModel.send(.setShowEditor(false)) + viewModel.send(.refresh) + } ) } .toolbar { diff --git a/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift index 1f489fab..93bb5d4a 100644 --- a/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift +++ b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -28,25 +29,31 @@ public struct TodoEditorWindowView: View { switch value { case .create(let windowCategory, _): TodoEditorView( - viewModel: TodoEditorViewModel( - category: windowCategory.todoCategory, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - onCreateSuccess: create - ), + store: Store(initialState: TodoEditorFeature.State(category: windowCategory.todoCategory)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve( + FetchTodoCategoryPreferencesUseCase.self + ) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + }, + onCreateSuccess: create, onClose: closeWindow ) case .edit(let windowTodo): TodoEditorView( - viewModel: TodoEditorViewModel( - todo: windowTodo.todo, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - onUpdateSuccess: update - ), + store: Store(initialState: TodoEditorFeature.State(todo: windowTodo.todo)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve( + FetchTodoCategoryPreferencesUseCase.self + ) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + }, + onUpdateSuccess: update, onClose: closeWindow ) } From 44a5ff47ae6931350182f54420424a6952d9bbd6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:11:42 +0900 Subject: [PATCH 02/12] =?UTF-8?q?chore:=20TodoEditorFeature=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/TodoEditorFeatureTestDoubles.swift | 324 ++++++++++++++++++ .../Tests/Home/TodoEditorFeatureTests.swift | 228 ++++++++++++ 2 files changed, 552 insertions(+) create mode 100644 Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift create mode 100644 Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift new file mode 100644 index 00000000..ee41c1cb --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -0,0 +1,324 @@ +// +// TodoEditorFeatureTestDoubles.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +let todoEditorNow = Date(timeIntervalSince1970: 1_000) + +@MainActor +final class TodoEditorStoreTestAdapter { + private let store: TestStoreOf + private let now: Date + + var isCompleted: Bool { store.state.isCompleted } + var completedAt: Date? { store.state.completedAt } + var isPinned: Bool { store.state.isPinned } + var title: String { store.state.title } + var content: String { store.state.content } + var referenceItems: [Int: TodoReferenceItem] { store.state.referenceItems } + var dueDate: Date? { store.state.dueDate } + var isLoading: Bool { store.state.isLoading } + var tags: [String] { Array(store.state.tags) } + var categories: [TodoCategoryItem] { store.state.categories } + var category: TodoCategoryItem { store.state.category } + var hasChanges: Bool { store.state.hasChanges } + var isReadyToSubmit: Bool { store.state.isReadyToSubmit } + var saveResult: TodoEditorFeature.SaveResult? { store.state.saveResult } + var hasErrorAlert: Bool { store.state.alert == expectedTodoEditorErrorAlert() } + + init( + category: TodoCategory, + now: Date = todoEditorNow, + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = TodoEditorFetchPreferencesUseCaseSpy(), + fetchReferenceItemsUseCase: FetchReferenceItemsUseCase = TodoEditorFetchReferenceItemsUseCaseSpy(), + upsertTodoUseCase: UpsertTodoUseCase = TodoEditorUpsertTodoUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil + ) { + self.now = now + store = TestStore( + initialState: TodoEditorFeature.State(category: category, id: "todo-draft-id") + ) { + TodoEditorFeature() + } withDependencies: { + $0.date.now = now + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + $0.upsertTodoUseCase = upsertTodoUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + init( + todo: Todo, + now: Date = todoEditorNow, + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = TodoEditorFetchPreferencesUseCaseSpy(), + fetchReferenceItemsUseCase: FetchReferenceItemsUseCase = TodoEditorFetchReferenceItemsUseCaseSpy(), + upsertTodoUseCase: UpsertTodoUseCase = TodoEditorUpsertTodoUseCaseSpy() + ) { + self.now = now + store = TestStore(initialState: TodoEditorFeature.State(todo: todo)) { + TodoEditorFeature() + } withDependencies: { + $0.date.now = now + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + $0.upsertTodoUseCase = upsertTodoUseCase + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func onAppear() async { + await store.send(.onAppear) + await drainReceivedActions() + } + + func addTag(_ tag: String) async { + await store.send(.addTag(tag)) { + if !tag.isEmpty { + $0.tags.append(tag) + } + } + } + + func removeTag(_ tag: String) async { + await store.send(.removeTag(tag)) { + $0.tags.removeAll { $0 == tag } + } + } + + func setContent(_ content: String) async { + await store.send(.setContent(content)) { + $0.content = content + } + await drainReceivedActions() + } + + func setCompleted(_ isCompleted: Bool) async { + let shouldUpdateDate = store.state.isCompleted != isCompleted + await store.send(.setCompleted(isCompleted)) { + if shouldUpdateDate { + $0.completedAt = isCompleted ? self.now : nil + } + $0.isCompleted = isCompleted + } + } + + func setDueDate(_ dueDate: Date?) async { + let expectedDueDate = expectedDueDate(for: dueDate) + await store.send(.setDueDate(dueDate)) { + $0.dueDate = expectedDueDate + } + } + + func setPinned(_ isPinned: Bool) async { + await store.send(.setPinned(isPinned)) { + $0.isPinned = isPinned + } + } + + func setTab(_ tab: TodoEditorFeature.Tag) async { + await store.send(.setTabViewTag(tab)) { + $0.tabViewTag = tab + } + await drainReceivedActions() + } + + func setTitle(_ title: String) async { + await store.send(.setTitle(title)) { + $0.title = title + } + await drainReceivedActions() + } + + func upsertTodo() async { + await store.send(.upsertTodo) { + $0.saveResult = nil + } + await store.receive(\.setLoading, true) { + $0.isLoading = true + } + } + + func drainReceivedActions() async { + await store.skipReceivedActions(strict: false) + await store.skipReceivedActions(strict: false) + await store.skipReceivedActions(strict: false) + } + + private func expectedDueDate(for dueDate: Date?) -> Date? { + guard let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), + let dueDate else { + return nil + } + + return max(dueDate, tomorrowDate) + } +} + +final class TodoEditorFetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { + var preferences: [TodoCategoryPreference] + private(set) var executeCallCount = 0 + + init(preferences: [TodoCategoryPreference] = []) { + self.preferences = preferences + } + + func execute() async throws -> [TodoCategoryPreference] { + executeCallCount += 1 + return preferences + } +} + +final class TodoEditorFetchReferenceItemsUseCaseSpy: 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 + } +} + +final class TodoEditorUpsertTodoUseCaseSpy: UpsertTodoUseCase { + var error: Error? + var shouldSuspend = false + private(set) var todos: [Todo] = [] + private(set) var todoDrafts: [TodoDraft] = [] + private var continuation: CheckedContinuation? + private var shouldResume = false + + func execute(_ todo: Todo) async throws { + todos.append(todo) + await suspendIfNeeded() + + if let error { + throw error + } + } + + func execute(_ draft: TodoDraft) async throws { + todoDrafts.append(draft) + await suspendIfNeeded() + + if let error { + throw error + } + } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } + + private func suspendIfNeeded() async { + guard shouldSuspend else { return } + + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } +} + +final class TodoEditorTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private(set) var events: [AnalyticsEvent] = [] + var hasTrackedTodoCreate: Bool { + events.contains { + guard case .todoCreate = $0 else { return false } + return true + } + } + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +enum TodoEditorTestError: Error { + case failure +} + +func expectedTodoEditorErrorAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } +} + +func makeTodoEditorTodo( + id: String = "todo-1", + isPinned: Bool = false, + isCompleted: Bool = false, + isChecked: Bool = false, + number: Int = 1, + title: String = "Todo", + content: String = "content", + createdAt: Date = Date(timeIntervalSince1970: 0), + updatedAt: Date = Date(timeIntervalSince1970: 0), + completedAt: Date? = nil, + deletedAt: Date? = nil, + dueDate: Date? = nil, + tags: [String] = [], + category: TodoCategory = .system(.doc) +) -> Todo { + Todo( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + number: number, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: tags, + category: category + ) +} + +func makeTodoEditorReference( + id: String, + title: String = "Reference" +) -> TodoReference { + TodoReference( + id: id, + title: title, + category: .system(.issue) + ) +} diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift new file mode 100644 index 00000000..ff729c6b --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift @@ -0,0 +1,228 @@ +// +// TodoEditorFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct TodoEditorFeatureTests { + @Test("새 Todo 작성 상태는 선택한 카테고리로 초기화된다") + func 새_Todo_작성_상태는_선택한_카테고리로_초기화된다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + + #expect(adapter.category == TodoCategoryItem(from: .system(.doc))) + #expect(adapter.categories == [TodoCategoryItem(from: .system(.doc))]) + #expect(adapter.title.isEmpty) + #expect(adapter.content.isEmpty) + #expect(adapter.tags.isEmpty) + #expect(adapter.isReadyToSubmit == false) + } + + @Test("기존 Todo 편집 상태는 Todo 값으로 초기화되고 변경 여부를 계산한다") + func 기존_Todo_편집_상태는_Todo_값으로_초기화되고_변경_여부를_계산한다() async { + let todo = makeTodoEditorTodo( + id: "todo-1", + isPinned: true, + isCompleted: true, + title: "Original", + content: "Body", + completedAt: Date(timeIntervalSince1970: 10), + dueDate: Date(timeIntervalSince1970: 20), + tags: ["swift", "tca"], + category: .system(.issue) + ) + let adapter = TodoEditorStoreTestAdapter(todo: todo) + + #expect(adapter.isPinned) + #expect(adapter.isCompleted) + #expect(adapter.title == "Original") + #expect(adapter.content == "Body") + #expect(adapter.completedAt == Date(timeIntervalSince1970: 10)) + #expect(adapter.dueDate == Date(timeIntervalSince1970: 20)) + #expect(adapter.tags == ["swift", "tca"]) + #expect(adapter.category == TodoCategoryItem(from: .system(.issue))) + #expect(adapter.hasChanges == false) + #expect(adapter.isReadyToSubmit == false) + + await adapter.setTitle("Changed") + + #expect(adapter.hasChanges) + #expect(adapter.isReadyToSubmit) + } + + @Test("onAppear는 Todo 카테고리 설정을 가져와 상태에 반영한다") + func onAppear는_Todo_카테고리_설정을_가져와_상태에_반영한다() async { + let fetchSpy = TodoEditorFetchPreferencesUseCaseSpy(preferences: [ + TodoCategoryPreference(category: .system(.doc), isVisible: true), + TodoCategoryPreference(category: .system(.issue), isVisible: false) + ]) + let adapter = TodoEditorStoreTestAdapter( + category: .system(.etc), + fetchPreferencesUseCase: fetchSpy + ) + + await adapter.onAppear() + + #expect(fetchSpy.executeCallCount == 1) + #expect(adapter.categories == [ + TodoCategoryItem(from: TodoCategoryPreference(category: .system(.doc), isVisible: true)), + TodoCategoryItem(from: TodoCategoryPreference(category: .system(.issue), isVisible: false)) + ]) + } + + @Test("태그 추가와 삭제는 OrderedSet 상태를 변경한다") + func 태그_추가와_삭제는_OrderedSet_상태를_변경한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + + await adapter.addTag("swift") + await adapter.addTag("tca") + await adapter.addTag("swift") + await adapter.removeTag("swift") + + #expect(adapter.tags == ["tca"]) + } + + @Test("완료 상태가 바뀔 때 completedAt을 함께 갱신한다") + func 완료_상태가_바뀔_때_completedAt을_함께_갱신한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + + await adapter.setCompleted(true) + + #expect(adapter.isCompleted) + #expect(adapter.completedAt == todoEditorNow) + + await adapter.setCompleted(false) + + #expect(adapter.isCompleted == false) + #expect(adapter.completedAt == nil) + } + + @Test("미래 마감일은 그대로 반영하고 nil 입력은 마감일을 제거한다") + func 미래_마감일은_그대로_반영하고_nil_입력은_마감일을_제거한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + let dueDate = Date(timeIntervalSince1970: 4_102_444_800) + + await adapter.setDueDate(dueDate) + + #expect(adapter.dueDate == dueDate) + + await adapter.setDueDate(nil) + + #expect(adapter.dueDate == nil) + } + + @Test("미리보기 전환은 본문의 참조 Todo를 해석해 상태에 반영한다") + func 미리보기_전환은_본문의_참조_Todo를_해석해_상태에_반영한다() async { + let reference3 = makeTodoEditorReference(id: "todo-3", title: "Reference 3") + let reference5 = makeTodoEditorReference(id: "todo-5", title: "Reference 5") + let referenceSpy = TodoEditorFetchReferenceItemsUseCaseSpy(references: [ + 3: reference3, + 5: reference5 + ]) + let adapter = TodoEditorStoreTestAdapter( + category: .system(.doc), + fetchReferenceItemsUseCase: referenceSpy + ) + + await adapter.setContent(""" + body + - refs #3 + - refs #5 + - refs #3 + """) + await adapter.setTab(.preview) + + #expect(referenceSpy.numbers == [[3, 5]]) + #expect(adapter.referenceItems[3] == TodoReferenceItem(from: reference3)) + #expect(adapter.referenceItems[5] == TodoReferenceItem(from: reference5)) + } + + @Test("새 Todo 저장 성공은 draft를 저장하고 생성 완료 상태를 남긴다") + func 새_Todo_저장_성공은_draft를_저장하고_생성_완료_상태를_남긴다() async { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + upsertSpy.shouldSuspend = true + let analyticsSpy = TodoEditorTrackAnalyticsEventUseCaseSpy() + let adapter = TodoEditorStoreTestAdapter( + category: .system(.doc), + upsertTodoUseCase: upsertSpy, + trackAnalyticsEventUseCase: analyticsSpy + ) + let dueDate = Date(timeIntervalSince1970: 4_102_444_800) + + await adapter.setTitle("Title") + await adapter.setContent("Content") + await adapter.setPinned(true) + await adapter.setDueDate(dueDate) + await adapter.addTag("swift") + await adapter.upsertTodo() + + #expect(adapter.isLoading) + #expect(upsertSpy.todoDrafts.first?.title == "Title") + #expect(upsertSpy.todoDrafts.first?.content == "Content") + #expect(upsertSpy.todoDrafts.first?.isPinned == true) + #expect(upsertSpy.todoDrafts.first?.dueDate == dueDate) + #expect(upsertSpy.todoDrafts.first?.tags == ["swift"]) + #expect(upsertSpy.todoDrafts.first?.category == .system(.doc)) + + upsertSpy.resume() + await adapter.drainReceivedActions() + + #expect(adapter.saveResult == .created) + #expect(adapter.isLoading == false) + #expect(analyticsSpy.hasTrackedTodoCreate) + } + + @Test("기존 Todo 저장 성공은 수정 Todo를 저장하고 수정 완료 상태를 남긴다") + func 기존_Todo_저장_성공은_수정_Todo를_저장하고_수정_완료_상태를_남긴다() async throws { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + let todo = makeTodoEditorTodo( + id: "todo-1", + number: 7, + title: "Original", + content: "Body", + createdAt: Date(timeIntervalSince1970: 10), + category: .system(.issue) + ) + let adapter = TodoEditorStoreTestAdapter(todo: todo, upsertTodoUseCase: upsertSpy) + + await adapter.setTitle("Changed") + await adapter.setContent("Changed body") + await adapter.upsertTodo() + await adapter.drainReceivedActions() + + let updated = try #require(upsertSpy.todos.first) + + #expect(updated.id == "todo-1") + #expect(updated.number == 7) + #expect(updated.title == "Changed") + #expect(updated.content == "Changed body") + #expect(updated.createdAt == Date(timeIntervalSince1970: 10)) + #expect(updated.updatedAt == todoEditorNow) + #expect(updated.category == .system(.issue)) + #expect(adapter.saveResult == .updated(updated)) + } + + @Test("저장 실패는 공통 에러 알림을 표시하고 로딩을 해제한다") + func 저장_실패는_공통_에러_알림을_표시하고_로딩을_해제한다() async { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + upsertSpy.error = TodoEditorTestError.failure + let adapter = TodoEditorStoreTestAdapter( + category: .system(.doc), + upsertTodoUseCase: upsertSpy + ) + + await adapter.setTitle("Title") + await adapter.upsertTodo() + await adapter.drainReceivedActions() + + #expect(adapter.hasErrorAlert) + #expect(adapter.isLoading == false) + #expect(adapter.saveResult == nil) + } +} From 0c3b19603c6bbf1420c4ed38cf3288d16922fee7 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:19:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20LoadingFeature=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Editor/TodoEditorFeature.swift | 24 ++++++++++++------- .../Home/TodoEditorFeatureTestDoubles.swift | 19 ++++++++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 14cfe026..213cb9dd 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -24,7 +24,7 @@ struct TodoEditorFeature { var referenceItems: [Int: TodoReferenceItem] = [:] var dueDate: Date? var showInfo: Bool = false - var isLoading: Bool = false + var loading = LoadingFeature.State() var tags: OrderedSet = [] var tagText: String = "" var focusOnEditor: Bool = false @@ -42,6 +42,9 @@ struct TodoEditorFeature { var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + var isLoading: Bool { + loading.isLoading + } var navigationTitle: String { if originalDraft == nil { return String.localizedStringWithFormat( @@ -99,7 +102,7 @@ struct TodoEditorFeature { case updated(Todo) } - enum Action { + enum Action: Equatable { case alert(PresentationAction) case onAppear case addTag(String) @@ -109,7 +112,6 @@ struct TodoEditorFeature { case setDueDate(Date?) case setCategory(TodoCategoryItem) case setAlert(Bool) - case setLoading(Bool) case setPinned(Bool) case setShowInfo(Bool) case setSelectedTodoId(TodoIdItem?) @@ -121,6 +123,7 @@ struct TodoEditorFeature { case upsertTodo case createSucceeded case updateSucceeded(Todo) + case loading(LoadingFeature.Action) } @Dependency(\.date.now) var now @@ -130,6 +133,9 @@ struct TodoEditorFeature { @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } Reduce { state, action in switch action { case .alert: @@ -173,8 +179,6 @@ struct TodoEditorFeature { state.category = item case .setAlert(let isPresented): state.alert = isPresented ? Self.alertState() : nil - case .setLoading(let value): - state.isLoading = value case .setPinned(let isPinned): state.isPinned = isPinned case .setShowInfo(let isPresented): @@ -201,6 +205,8 @@ struct TodoEditorFeature { state.saveResult = .created case .updateSucceeded(let todo): state.saveResult = .updated(todo) + case .loading: + break } return .none @@ -280,7 +286,7 @@ private extension TodoEditorFeature { func createTodoEffect(_ draft: TodoDraft) -> Effect { .run { [trackAnalyticsEventUseCase, upsertTodoUseCase] send in - await send(.setLoading(true)) + await send(.loading(.begin(target: .default, mode: .immediate))) do { try await upsertTodoUseCase.execute(draft) trackAnalyticsEventUseCase?.execute(.todoCreate) @@ -288,20 +294,20 @@ private extension TodoEditorFeature { } catch { await send(.setAlert(true)) } - await send(.setLoading(false)) + await send(.loading(.end(target: .default, mode: .immediate))) } } func updateTodoEffect(_ todo: Todo) -> Effect { .run { [upsertTodoUseCase] send in - await send(.setLoading(true)) + await send(.loading(.begin(target: .default, mode: .immediate))) do { try await upsertTodoUseCase.execute(todo) await send(.updateSucceeded(todo)) } catch { await send(.setAlert(true)) } - await send(.setLoading(false)) + await send(.loading(.end(target: .default, mode: .immediate))) } } diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift index ee41c1cb..d775f10b 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -143,9 +143,7 @@ final class TodoEditorStoreTestAdapter { await store.send(.upsertTodo) { $0.saveResult = nil } - await store.receive(\.setLoading, true) { - $0.isLoading = true - } + await receiveBeginLoading() } func drainReceivedActions() async { @@ -162,6 +160,21 @@ final class TodoEditorStoreTestAdapter { return max(dueDate, tomorrowDate) } + + private func receiveBeginLoading() async { + await store.receive(.loading(.begin(target: .default, mode: .immediate))) { + $0.loading.setImmediateLoading() + } + } +} + +private extension LoadingFeature.State { + mutating func setImmediateLoading() { + let target = LoadingFeature.Target.default + immediateCountByTarget[target] = 1 + visibleTargets.insert(target) + isLoading = !visibleTargets.isEmpty + } } final class TodoEditorFetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { From 2b2692234a5a1caa32f7e23d6ef1c2169c26a4af Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:29:14 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20State,=20Action=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Editor/TodoEditorFeature.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 213cb9dd..65e1c721 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -27,7 +27,6 @@ struct TodoEditorFeature { var loading = LoadingFeature.State() var tags: OrderedSet = [] var tagText: String = "" - var focusOnEditor: Bool = false var tabViewTag: Tag = .editor var categories: [TodoCategoryItem] = [] var category = TodoCategoryItem(from: .system(.etc)) @@ -111,7 +110,6 @@ struct TodoEditorFeature { case setCompleted(Bool) case setDueDate(Date?) case setCategory(TodoCategoryItem) - case setAlert(Bool) case setPinned(Bool) case setShowInfo(Bool) case setSelectedTodoId(TodoIdItem?) @@ -122,6 +120,7 @@ struct TodoEditorFeature { case setReferenceItems([Int: TodoReferenceItem]) case upsertTodo case createSucceeded + case saveFailed case updateSucceeded(Todo) case loading(LoadingFeature.Action) } @@ -177,8 +176,6 @@ struct TodoEditorFeature { state.isCompleted = isCompleted case .setCategory(let item): state.category = item - case .setAlert(let isPresented): - state.alert = isPresented ? Self.alertState() : nil case .setPinned(let isPinned): state.isPinned = isPinned case .setShowInfo(let isPresented): @@ -203,6 +200,8 @@ struct TodoEditorFeature { } case .createSucceeded: state.saveResult = .created + case .saveFailed: + state.alert = Self.alertState() case .updateSucceeded(let todo): state.saveResult = .updated(todo) case .loading: @@ -292,7 +291,7 @@ private extension TodoEditorFeature { trackAnalyticsEventUseCase?.execute(.todoCreate) await send(.createSucceeded) } catch { - await send(.setAlert(true)) + await send(.saveFailed) } await send(.loading(.end(target: .default, mode: .immediate))) } @@ -305,7 +304,7 @@ private extension TodoEditorFeature { try await upsertTodoUseCase.execute(todo) await send(.updateSucceeded(todo)) } catch { - await send(.setAlert(true)) + await send(.saveFailed) } await send(.loading(.end(target: .default, mode: .immediate))) } From 27c9aba03cec4365de5f7ec9e0ce7992a206195d Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:39:09 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=9D=B4=ED=8E=99=ED=8A=B8=20=EB=B0=98=ED=99=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Editor/TodoEditorFeature.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 65e1c721..369b4463 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -154,14 +154,8 @@ struct TodoEditorFeature { } case .setTagText(let tagText): state.tagText = tagText - if state.tabViewTag == .preview { - return resolveMarkdownEffect(content: state.content) - } case .setTitle(let title): state.title = title - if state.tabViewTag == .preview { - return resolveMarkdownEffect(content: state.content) - } case .setDueDate(let dueDate): if let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), let dueDate { From 686f4d398d291b4cc0fe3c63a1ef74684f3658e0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:54:30 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20BindingAction=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Editor/TodoEditorFeature.swift | 69 ++++++------------- .../Sources/Home/Editor/TodoEditorView.swift | 47 +++++-------- .../Home/TodoEditorFeatureTestDoubles.swift | 10 +-- 3 files changed, 43 insertions(+), 83 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 369b4463..e248c8d3 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -101,23 +101,13 @@ struct TodoEditorFeature { case updated(Todo) } - enum Action: Equatable { + enum Action: BindableAction, Equatable { case alert(PresentationAction) + case binding(BindingAction) case onAppear case addTag(String) case removeTag(String) - case setContent(String) case setCompleted(Bool) - case setDueDate(Date?) - case setCategory(TodoCategoryItem) - case setPinned(Bool) - case setShowInfo(Bool) - case setSelectedTodoId(TodoIdItem?) - case setTabViewTag(Tag) - case setTagText(String) - case setTitle(String) - case setCategories([TodoCategoryItem]) - case setReferenceItems([Int: TodoReferenceItem]) case upsertTodo case createSucceeded case saveFailed @@ -135,56 +125,41 @@ struct TodoEditorFeature { Scope(state: \.loading, action: \.loading) { LoadingFeature() } + BindingReducer() Reduce { state, action in switch action { case .alert: break - case .onAppear: - return fetchCategoriesEffect() - case .addTag(let tag): - if !tag.isEmpty { - state.tags.append(tag) - } - case .removeTag(let tagText): - state.tags.removeAll { $0 == tagText } - case .setContent(let content): - state.content = content + case .binding(\.content): if state.tabViewTag == .preview { return resolveMarkdownEffect(content: state.content) } - case .setTagText(let tagText): - state.tagText = tagText - case .setTitle(let title): - state.title = title - case .setDueDate(let dueDate): + case .binding(\.dueDate): if let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), - let dueDate { + let dueDate = state.dueDate { state.dueDate = max(dueDate, tomorrowDate) } else { state.dueDate = nil } + case .binding(\.tabViewTag): + if state.tabViewTag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .binding: + break + case .onAppear: + return fetchCategoriesEffect() + case .addTag(let tag): + if !tag.isEmpty { + state.tags.append(tag) + } + case .removeTag(let tagText): + state.tags.removeAll { $0 == tagText } case .setCompleted(let isCompleted): if state.isCompleted != isCompleted { state.completedAt = isCompleted ? now : nil } state.isCompleted = isCompleted - case .setCategory(let item): - state.category = item - case .setPinned(let isPinned): - state.isPinned = isPinned - case .setShowInfo(let isPresented): - state.showInfo = isPresented - case .setSelectedTodoId(let todoId): - state.selectedTodoId = todoId - case .setTabViewTag(let tag): - state.tabViewTag = tag - if tag == .preview { - return resolveMarkdownEffect(content: state.content) - } - case .setCategories(let categories): - state.categories = categories - case .setReferenceItems(let items): - state.referenceItems = items case .upsertTodo: state.saveResult = nil if state.originalDraft == nil { @@ -254,7 +229,7 @@ private extension TodoEditorFeature { .run { [fetchPreferencesUseCase] send in do { let preferences = try await fetchPreferencesUseCase.execute() - await send(.setCategories(preferences.map(TodoCategoryItem.init(from:)))) + await send(.binding(.set(\.categories, preferences.map(TodoCategoryItem.init(from:))))) } catch { } } } @@ -273,7 +248,7 @@ private extension TodoEditorFeature { } } - await send(.setReferenceItems(referenceItems)) + await send(.binding(.set(\.referenceItems, referenceItems))) } } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index b9e707a5..8668cdbe 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -53,18 +53,12 @@ struct TodoEditorView: View { .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) - .sheet(isPresented: Binding( - get: { store.showInfo }, - set: { store.send(.setShowInfo($0)) } - )) { + .sheet(isPresented: $store.showInfo) { TodoEditorInfoSheetView(store: store) { - store.send(.setShowInfo(false)) + store.send(.binding(.set(\.showInfo, false))) } } - .sheet(item: Binding( - get: { store.selectedTodoId }, - set: { store.send(.setSelectedTodoId($0)) } - )) { item in + .sheet(item: $store.selectedTodoId) { item in NavigationStack { TodoDetailView(store: Store( initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) @@ -76,7 +70,7 @@ struct TodoEditorView: View { }) .toolbar { ToolbarLeadingButton { - store.send(.setSelectedTodoId(nil)) + store.send(.binding(.set(\.selectedTodoId, nil))) } } } @@ -89,7 +83,7 @@ struct TodoEditorView: View { } ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.setShowInfo(true)) + store.send(.binding(.set(\.showInfo, true))) } label: { Image(systemName: "info.circle") } @@ -122,10 +116,7 @@ struct TodoEditorView: View { private var titleField: some View { TextField( "", - text: Binding( - get: { store.title }, - set: { store.send(.setTitle($0)) } - ), + text: $store.title, prompt: Text(String(localized: "todo_editor_title_required")).foregroundColor(Color.secondary), ) .font(.title2) @@ -140,7 +131,7 @@ struct TodoEditorView: View { get: { store.tabViewTag }, set: { tag in if tag == .editor { - store.send(.setTabViewTag(.editor)) + store.send(.binding(.set(\.tabViewTag, .editor))) field = .content } else { transitionToPreview() @@ -162,10 +153,7 @@ struct TodoEditorView: View { VStack(alignment: .leading, spacing: 8) { markdownHint UIKitTextEditor( - text: Binding( - get: { store.content }, - set: { store.send(.setContent($0)) } - ), + text: $store.content, placeholder: String(localized: "todo_editor_description_optional") ) .focused($field, equals: .content) @@ -177,7 +165,7 @@ struct TodoEditorView: View { TodoMarkdownContentView( content: store.content, referenceItems: store.referenceItems, - onOpenTodoID: { store.send(.setSelectedTodoId(TodoIdItem(id: $0))) } + onOpenTodoID: { store.send(.binding(.set(\.selectedTodoId, TodoIdItem(id: $0)))) } ) } } @@ -220,7 +208,7 @@ struct TodoEditorView: View { field = nil DispatchQueue.main.async { - store.send(.setTabViewTag(.preview)) + store.send(.binding(.set(\.tabViewTag, .preview))) } } @@ -261,7 +249,7 @@ private struct TodoEditorInfoSheetView: View { return } - store.send(.setCategory(item)) + store.send(.binding(.set(\.category, item))) } ) ) { @@ -284,7 +272,7 @@ private struct TodoEditorInfoSheetView: View { String(localized: "todo_pinned"), isOn: Binding( get: { store.isPinned }, - set: { store.send(.setPinned($0)) } + set: { store.send(.binding(.set(\.isPinned, $0))) } ) ) .tint(.blue) @@ -296,10 +284,7 @@ private struct TodoEditorInfoSheetView: View { HStack(spacing: 12) { TextField( String(localized: "todo_add"), - text: Binding( - get: { store.tagText }, - set: { store.send(.setTagText($0)) } - ) + text: $store.tagText ) .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) .textInputAutocapitalization(.never) @@ -346,7 +331,7 @@ private struct TodoEditorInfoSheetView: View { private var dueDateControl: some View { DueDatePicker(selection: Binding( get: { store.dueDate ?? Date() }, - set: { store.send(.setDueDate($0)) } + set: { store.send(.binding(.set(\.dueDate, $0))) } )) { HStack { Text(String(localized: "todo_due_date")) @@ -354,7 +339,7 @@ private struct TodoEditorInfoSheetView: View { Spacer() if let dueDate = store.dueDate { Tag(dueDateText(for: dueDate), isEditing: true) { - store.send(.setDueDate(nil)) + store.send(.binding(.set(\.dueDate, nil))) } .padding(.vertical, -4) } else { @@ -370,7 +355,7 @@ private struct TodoEditorInfoSheetView: View { let tagText = normalizedTagText store.send(.addTag(tagText)) - store.send(.setTagText("")) + store.send(.binding(.set(\.tagText, ""))) } private var normalizedTagText: String { diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift index d775f10b..579ad67a 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -96,7 +96,7 @@ final class TodoEditorStoreTestAdapter { } func setContent(_ content: String) async { - await store.send(.setContent(content)) { + await store.send(.binding(.set(\.content, content))) { $0.content = content } await drainReceivedActions() @@ -114,26 +114,26 @@ final class TodoEditorStoreTestAdapter { func setDueDate(_ dueDate: Date?) async { let expectedDueDate = expectedDueDate(for: dueDate) - await store.send(.setDueDate(dueDate)) { + await store.send(.binding(.set(\.dueDate, dueDate))) { $0.dueDate = expectedDueDate } } func setPinned(_ isPinned: Bool) async { - await store.send(.setPinned(isPinned)) { + await store.send(.binding(.set(\.isPinned, isPinned))) { $0.isPinned = isPinned } } func setTab(_ tab: TodoEditorFeature.Tag) async { - await store.send(.setTabViewTag(tab)) { + await store.send(.binding(.set(\.tabViewTag, tab))) { $0.tabViewTag = tab } await drainReceivedActions() } func setTitle(_ title: String) async { - await store.send(.setTitle(title)) { + await store.send(.binding(.set(\.title, title))) { $0.title = title } await drainReceivedActions() From 9f9b0cb5139d2c682a05e37e64364f4cb713279c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:58:40 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20TodoEditorFeature=20=EB=B6=88?= =?UTF-8?q?=EB=B3=80=20=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Editor/TodoEditorFeature.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index e248c8d3..808c3f3d 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -31,12 +31,12 @@ struct TodoEditorFeature { var categories: [TodoCategoryItem] = [] var category = TodoCategoryItem(from: .system(.etc)) var saveResult: SaveResult? - var id: String - var isChecked: Bool - var number: Int? - var createdAt: Date? - var deletedAt: Date? - var originalDraft: TodoDraft? + let id: String + let isChecked: Bool + let number: Int? + let createdAt: Date? + let deletedAt: Date? + let originalDraft: TodoDraft? var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty From e6bc64b46d6c01f43b089229928a8b8eba0edb3c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:00:58 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20TodoEditorFeature=20=ED=83=AD?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=9D=B4=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Editor/TodoEditorFeature.swift | 4 ++-- .../Sources/Home/Editor/TodoEditorView.swift | 4 ++-- .../Tests/Home/TodoEditorFeatureTestDoubles.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 808c3f3d..04ca0a4c 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -27,7 +27,7 @@ struct TodoEditorFeature { var loading = LoadingFeature.State() var tags: OrderedSet = [] var tagText: String = "" - var tabViewTag: Tag = .editor + var tabViewTag: EditorTab = .editor var categories: [TodoCategoryItem] = [] var category = TodoCategoryItem(from: .system(.etc)) var saveResult: SaveResult? @@ -91,7 +91,7 @@ struct TodoEditorFeature { } } - enum Tag: Equatable { + enum EditorTab: Equatable { case editor case preview } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 8668cdbe..41d3390e 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -140,9 +140,9 @@ struct TodoEditorView: View { ) ) { Text(String(localized: "todo_write")) - .tag(TodoEditorFeature.Tag.editor) + .tag(TodoEditorFeature.EditorTab.editor) Text(String(localized: "todo_preview")) - .tag(TodoEditorFeature.Tag.preview) + .tag(TodoEditorFeature.EditorTab.preview) } .pickerStyle(.segmented) } diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift index 579ad67a..06d9752d 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -125,7 +125,7 @@ final class TodoEditorStoreTestAdapter { } } - func setTab(_ tab: TodoEditorFeature.Tag) async { + func setTab(_ tab: TodoEditorFeature.EditorTab) async { await store.send(.binding(.set(\.tabViewTag, tab))) { $0.tabViewTag = tab } From 9dcd3c1b2873da4b4f3c837217bac4d31112efaf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:06:26 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20sheet=EC=9A=A9=20State=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 --- .../Home/Editor/TodoEditorFeature.swift | 36 ++++++++++- .../Sources/Home/Editor/TodoEditorView.swift | 59 +++++++++++-------- .../Home/TodoEditorFeatureTestDoubles.swift | 19 ++++++ .../Tests/Home/TodoEditorFeatureTests.swift | 22 +++++++ 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 04ca0a4c..056089d9 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -15,15 +15,14 @@ struct TodoEditorFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + @Presents var sheet: SheetState? var isCompleted: Bool = false var completedAt: Date? var isPinned: Bool = false - var selectedTodoId: TodoIdItem? var title: String = "" var content: String = "" var referenceItems: [Int: TodoReferenceItem] = [:] var dueDate: Date? - var showInfo: Bool = false var loading = LoadingFeature.State() var tags: OrderedSet = [] var tagText: String = "" @@ -91,6 +90,13 @@ struct TodoEditorFeature { } } + @ObservableState + @CasePathable + enum SheetState: Equatable { + case info + case todo(TodoIdItem) + } + enum EditorTab: Equatable { case editor case preview @@ -103,16 +109,22 @@ struct TodoEditorFeature { enum Action: BindableAction, Equatable { case alert(PresentationAction) + case sheet(PresentationAction) case binding(BindingAction) case onAppear case addTag(String) case removeTag(String) case setCompleted(Bool) + case setSheet(SheetState?) case upsertTodo case createSucceeded case saveFailed case updateSucceeded(Todo) case loading(LoadingFeature.Action) + + enum Sheet: Equatable { + case tapCloseButton + } } @Dependency(\.date.now) var now @@ -130,6 +142,12 @@ struct TodoEditorFeature { switch action { case .alert: break + case .sheet(.dismiss): + state.sheet = nil + case .sheet(.presented(.tapCloseButton)): + state.sheet = nil + case .sheet: + break case .binding(\.content): if state.tabViewTag == .preview { return resolveMarkdownEffect(content: state.content) @@ -160,6 +178,8 @@ struct TodoEditorFeature { state.completedAt = isCompleted ? now : nil } state.isCompleted = isCompleted + case .setSheet(let sheet): + state.sheet = sheet case .upsertTodo: state.saveResult = nil if state.originalDraft == nil { @@ -180,6 +200,18 @@ struct TodoEditorFeature { return .none } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$sheet, action: \.sheet) { + TodoEditorSheetFeature() + } + } +} + +private struct TodoEditorSheetFeature: Reducer { + typealias State = TodoEditorFeature.SheetState + typealias Action = TodoEditorFeature.Action.Sheet + + var body: some ReducerOf { + EmptyReducer() } } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 41d3390e..54cf690b 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -53,29 +53,8 @@ struct TodoEditorView: View { .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) - .sheet(isPresented: $store.showInfo) { - TodoEditorInfoSheetView(store: store) { - store.send(.binding(.set(\.showInfo, false))) - } - } - .sheet(item: $store.selectedTodoId) { item in - 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 { - store.send(.binding(.set(\.selectedTodoId, nil))) - } - } - } - .background(Color(.systemGroupedBackground)) - .presentationDragIndicator(.visible) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in + sheetContent(sheetStore) } .toolbar { if !isiOSAppOnMac { @@ -83,7 +62,7 @@ struct TodoEditorView: View { } ToolbarItem(placement: .topBarTrailing) { Button { - store.send(.binding(.set(\.showInfo, true))) + store.send(.setSheet(.info)) } label: { Image(systemName: "info.circle") } @@ -165,7 +144,7 @@ struct TodoEditorView: View { TodoMarkdownContentView( content: store.content, referenceItems: store.referenceItems, - onOpenTodoID: { store.send(.binding(.set(\.selectedTodoId, TodoIdItem(id: $0)))) } + onOpenTodoID: { store.send(.setSheet(.todo(TodoIdItem(id: $0)))) } ) } } @@ -223,6 +202,36 @@ struct TodoEditorView: View { } } + @ViewBuilder + private func sheetContent( + _ sheetStore: Store + ) -> some View { + switch sheetStore.state { + case .info: + TodoEditorInfoSheetView(store: store) { + sheetStore.send(.tapCloseButton) + } + case .todo(let item): + 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) + } + } + } + .background(Color(.systemGroupedBackground)) + .presentationDragIndicator(.visible) + } + } + private enum Field: Hashable { case title, content } diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift index 06d9752d..4ba816c4 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -25,6 +25,7 @@ final class TodoEditorStoreTestAdapter { var content: String { store.state.content } var referenceItems: [Int: TodoReferenceItem] { store.state.referenceItems } var dueDate: Date? { store.state.dueDate } + var sheet: TodoEditorFeature.SheetState? { store.state.sheet } var isLoading: Bool { store.state.isLoading } var tags: [String] { Array(store.state.tags) } var categories: [TodoCategoryItem] { store.state.categories } @@ -112,6 +113,24 @@ final class TodoEditorStoreTestAdapter { } } + func setSheet(_ sheet: TodoEditorFeature.SheetState?) async { + await store.send(.setSheet(sheet)) { + $0.sheet = sheet + } + } + + func dismissSheet() async { + await store.send(.sheet(.dismiss)) { + $0.sheet = nil + } + } + + func tapSheetCloseButton() async { + await store.send(.sheet(.presented(.tapCloseButton))) { + $0.sheet = nil + } + } + func setDueDate(_ dueDate: Date?) async { let expectedDueDate = expectedDueDate(for: dueDate) await store.send(.binding(.set(\.dueDate, dueDate))) { diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift index ff729c6b..e93283d0 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift @@ -143,6 +143,28 @@ struct TodoEditorFeatureTests { #expect(adapter.referenceItems[5] == TodoReferenceItem(from: reference5)) } + @Test("정보와 참조 Todo 시트 상태를 액션에 맞게 변경한다") + func 정보와_참조_Todo_시트_상태를_액션에_맞게_변경한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + let item = TodoIdItem(id: "todo-2") + + await adapter.setSheet(.info) + + #expect(adapter.sheet == .info) + + await adapter.dismissSheet() + + #expect(adapter.sheet == nil) + + await adapter.setSheet(.todo(item)) + + #expect(adapter.sheet == .todo(item)) + + await adapter.tapSheetCloseButton() + + #expect(adapter.sheet == nil) + } + @Test("새 Todo 저장 성공은 draft를 저장하고 생성 완료 상태를 남긴다") func 새_Todo_저장_성공은_draft를_저장하고_생성_완료_상태를_남긴다() async { let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() From 18329ff98e62a716be275a4b80583759a7a0a5d3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:48:41 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20cancel=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Editor/TodoEditorFeature.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index 056089d9..ce6c4f78 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -127,6 +127,10 @@ struct TodoEditorFeature { } } + private enum CancelID: Hashable { + case resolveMarkdown + } + @Dependency(\.date.now) var now @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase @Dependency(\.fetchReferenceItemsUseCase) var fetchReferenceItemsUseCase @@ -282,6 +286,7 @@ private extension TodoEditorFeature { await send(.binding(.set(\.referenceItems, referenceItems))) } + .cancellable(id: CancelID.resolveMarkdown, cancelInFlight: true) } func createTodoEffect(_ draft: TodoDraft) -> Effect { From 0ab54bed7bda410abcf863021419c76dacb8488e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:01:45 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20Todo=20Store=20=EC=83=9D?= =?UTF-8?q?=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift | 2 +- .../DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 245c6f6c..57fd4a8c 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -14,7 +14,7 @@ struct TodoDetailView: View { @Environment(\.diContainer) private var container: DIContainer @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { ZStack { diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 54cf690b..766f5014 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -15,7 +15,7 @@ struct TodoEditorView: View { @Environment(\.diContainer) private var container: DIContainer @Environment(\.dismiss) private var dismiss @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @Bindable var store: StoreOf + @State var store: StoreOf @FocusState private var field: Field? private let calendar = Calendar.current var onCreateSuccess: (() -> Void)? From e02650cc33a2a9a97feb003cd1f4163c397efd25 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:04:03 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refactor:=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=20Store=20=EC=83=9D=EB=AA=85=EC=A3=BC?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Search/SearchView.swift | 2 +- .../DevLogPresentation/Sources/Settings/AccountView.swift | 2 +- .../Sources/Settings/PushNotificationSettingsView.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Search/SearchView.swift b/Application/DevLogPresentation/Sources/Search/SearchView.swift index 32e112dc..80e4a637 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchView.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchView.swift @@ -14,7 +14,7 @@ struct SearchView: View { @Environment(\.dismiss) private var dismiss @Environment(\.diContainer) private var container: DIContainer @State private var router = NavigationRouter() - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { NavigationStack(path: $router.path) { diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index ac9efede..28c664de 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -10,7 +10,7 @@ import ComposableArchitecture import DevLogDomain struct AccountView: View { - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { List { diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index d7847c98..88d7a481 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -9,7 +9,7 @@ import SwiftUI import ComposableArchitecture struct PushNotificationSettingsView: View { - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { List {