From 799b0505b67287aab6aaf83a3b365ad808306858 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:29:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20TCA=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EB=B2=94=EC=9C=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/ProjectDescriptionHelpers/Project+Packages.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index 9562fd0a..3e7f449c 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -11,7 +11,7 @@ public enum DevLogPackages { ) public static let composableArchitecturePackage: Package = .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - .upToNextMajor(from: "1.25.5") + .upToNextMinor(from: "1.25.5") ) public static let firebasePackage: Package = .package( url: "https://github.com/firebase/firebase-ios-sdk", From c735328cfbfbf7b9ed3faf4cbe0fcf64f14cbcd4 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:30:16 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20TodoManageView=20TCA=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 --- .../Sources/Home/Home/HomeView.swift | 2 +- .../Home/Home/HomeViewCoordinator.swift | 4 - .../Sources/Home/TodoManageFeature.swift | 200 +++++++++++++++ .../Sources/Home/TodoManageView.swift | 121 ++++----- .../Sources/Home/TodoManageViewModel.swift | 242 ------------------ .../Tests/Home/TodoManageFeatureTests.swift | 213 +++++++++++++++ 6 files changed, 462 insertions(+), 320 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift delete mode 100644 Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift create mode 100644 Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 8e371f23..d947c98d 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -29,7 +29,7 @@ struct HomeView: View { set: { coordinator.viewModel.send(.setPresentation(.reorderTodo, $0)) } )) { TodoManageView( - viewModel: coordinator.makeTodoManageViewModel(), + preferences: coordinator.viewModel.state.preferences, onDismiss: { array in coordinator.viewModel.send(.setPresentation(.reorderTodo, false)) withAnimation { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 8cdb3a89..9ab2d22c 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -76,10 +76,6 @@ final class HomeViewCoordinator { .store(in: &cancellables) } - func makeTodoManageViewModel() -> TodoManageViewModel { - TodoManageViewModel(viewModel.state.preferences) - } - func makeTodoEditorViewModel(category: TodoCategory) -> TodoEditorViewModel { TodoEditorViewModel( category: category, diff --git a/Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift b/Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift new file mode 100644 index 00000000..628aec6d --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift @@ -0,0 +1,200 @@ +// +// TodoManageFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/11/26. +// + +import ComposableArchitecture +import DevLogDomain +import SwiftUI + +@Reducer +struct TodoManageFeature { + @ObservableState + struct State: Equatable { + var preferences: [TodoCategoryItem] + @Presents var categorySheet: CategorySheetState? + @Presents var alert: AlertState? + } + + @ObservableState + struct CategorySheetState: Equatable { + var category: UserTodoCategory + var preferences: [TodoCategoryItem] + + var isEditing: Bool { + preferences.contains { $0.id == category.id } + } + var navigationTitle: String { + isEditing + ? String(localized: "todo_manage_edit_category_title") + : String(localized: "todo_manage_add_category_title") + } + var submitTitle: String { + isEditing + ? String(localized: "todo_manage_save") + : String(localized: "todo_add") + } + var placeholder: String { + category.name + } + var categoryNameCountText: String { + "\(category.name.count)/20" + } + var canSubmitUserCategory: Bool { + let name = category.name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + return false + } + + if SystemTodoCategory.allCases.contains(where: { + $0.rawValue.caseInsensitiveCompare(name) == .orderedSame + }) { + return false + } + + if preferences.contains(where: { item in + guard case .user(let userCategory) = item.category, userCategory.id != category.id else { + return false + } + + return userCategory.name.caseInsensitiveCompare(name) == .orderedSame + }) { + return false + } + + if let item = preferences.first(where: { $0.id == category.id }) { + if case .user(let originalCategory) = item.category { + let originalName = originalCategory.name.trimmingCharacters(in: .whitespacesAndNewlines) + if originalName == name && originalCategory.colorHex == category.colorHex { + return false + } + } + } + + return true + } + var todoCategoryItem: TodoCategoryItem { + TodoCategoryItem( + from: .user( + UserTodoCategory( + id: category.id, + name: category.name.trimmingCharacters(in: .whitespacesAndNewlines), + colorHex: category.colorHex + ) + ) + ) + } + } + + enum Action { + case alert(PresentationAction) + case categorySheet(PresentationAction) + case tapAddUserCategory + case moveItem(from: IndexSet, target: Int) + case tapItem(TodoCategoryItem) + case tapEditUserCategory(TodoCategoryItem) + case tapDeleteUserCategory(TodoCategoryItem) + case tapDoneButton + + enum Alert: Equatable { + case confirmDeleteUserCategory(TodoCategoryItem) + } + + enum CategorySheet: Equatable { + case setCategoryName(String) + case setCategoryColor(String) + case tapCloseButton + case tapRandomColorButton + case tapSaveButton + } + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert(.presented(.confirmDeleteUserCategory(let item))): + if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { + state.preferences.remove(at: index) + } + case .alert: + break + case .categorySheet(.dismiss): + state.categorySheet = nil + case .categorySheet(.presented(.tapCloseButton)): + state.categorySheet = nil + case .categorySheet(.presented(.setCategoryName(let name))): + state.categorySheet?.category.name = String(name.prefix(20)) + case .categorySheet(.presented(.setCategoryColor(let colorHex))): + state.categorySheet?.category.colorHex = colorHex + case .categorySheet(.presented(.tapRandomColorButton)): + if let randomHexValue = Color.randomValue.hexValue { + state.categorySheet?.category.colorHex = randomHexValue + } + case .categorySheet(.presented(.tapSaveButton)): + if var item = state.categorySheet?.todoCategoryItem { + if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { + item.isVisible = state.preferences[index].isVisible + state.preferences[index] = item + } else { + state.preferences.append(item) + } + + state.categorySheet = nil + } + case .categorySheet: + break + case .tapAddUserCategory: + if let randomHexValue = Color.randomValue.hexValue { + state.categorySheet = CategorySheetState( + category: UserTodoCategory( + id: UUID().uuidString.lowercased(), + name: "", + colorHex: randomHexValue + ), + preferences: state.preferences + ) + } + case .moveItem(let from, let target): + state.preferences.move(fromOffsets: from, toOffset: target) + case .tapItem(let item): + if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { + state.preferences[index].isVisible.toggle() + } + case .tapEditUserCategory(let item): + if item.isUserCategory, case .user(let category) = item.category { + state.categorySheet = CategorySheetState( + category: category, + preferences: state.preferences + ) + } + case .tapDeleteUserCategory(let item): + if item.isUserCategory { + state.alert = deleteAlertState(for: item) + } + case .tapDoneButton: + break + } + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +private extension TodoManageFeature { + func deleteAlertState(for item: TodoCategoryItem) -> AlertState { + AlertState { + TextState(String(localized: "todo_manage_delete_category_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_cancel")) + } + ButtonState(role: .destructive, action: .confirmDeleteUserCategory(item)) { + TextState(String(localized: "common_delete")) + } + } message: { + TextState(String(localized: "todo_manage_delete_category_message")) + } + } +} diff --git a/Application/DevLogPresentation/Sources/Home/TodoManageView.swift b/Application/DevLogPresentation/Sources/Home/TodoManageView.swift index 268c1d52..05bd1e5c 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoManageView.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoManageView.swift @@ -6,29 +6,41 @@ // import SwiftUI +import ComposableArchitecture import DevLogDomain struct TodoManageView: View { - @State var viewModel: TodoManageViewModel - @State private var tmpText = "" + @State private var store: StoreOf var onDismiss: (([TodoCategoryItem]) -> Void)? + init( + preferences: [TodoCategoryItem], + onDismiss: (([TodoCategoryItem]) -> Void)? + ) { + self._store = State(initialValue: Store( + initialState: TodoManageFeature.State(preferences: preferences) + ) { + TodoManageFeature() + }) + self.onDismiss = onDismiss + } + var body: some View { NavigationStack { List { - ForEach(viewModel.state.preferences, id: \.id) { item in + ForEach(store.preferences, id: \.id) { item in HStack(spacing: 0) { CheckBox(isChecked: item.isVisible, font: .title3) .padding(.horizontal) .onTapGesture { - viewModel.send(.tapItem(item)) + store.send(.tapItem(item)) } Text(item.localizedName) .lineLimit(1) Spacer() if item.isUserCategory { Button { - viewModel.send(.tapEditUserCategory(item)) + store.send(.tapEditUserCategory(item)) } label: { Image(systemName: "slider.horizontal.3") } @@ -36,7 +48,7 @@ struct TodoManageView: View { .padding(.trailing, 8) Button(role: .destructive) { - viewModel.send(.tapDeleteUserCategory(item)) + store.send(.tapDeleteUserCategory(item)) } label: { Image(systemName: "trash") } @@ -46,7 +58,7 @@ struct TodoManageView: View { } } .onMove { source, destination in - viewModel.send(.moveItem(from: source, target: destination)) + store.send(.moveItem(from: source, target: destination)) } .listRowInsets(EdgeInsets()) } @@ -54,33 +66,14 @@ struct TodoManageView: View { .navigationTitle(String(localized: "nav_todo_manage")) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() - .sheet(isPresented: Binding( - get: { viewModel.state.showSheet }, - set: { viewModel.send(.setShowSheet($0)) } - )) { - categorySheet - } - .alert( - String(localized: "todo_manage_delete_category_title"), - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setShowAlert($0)) } - ) - ) { - Button(String(localized: "common_cancel"), role: .cancel) { - viewModel.send(.setShowAlert(false)) - } - Button(String(localized: "common_delete"), role: .destructive) { - viewModel.send(.confirmDeleteUserCategory) - } - } message: { - Text(String(localized: "todo_manage_delete_category_message")) - .multilineTextAlignment(.leading) + .sheet(item: $store.scope(state: \.categorySheet, action: \.categorySheet)) { store in + TodoManageCategorySheet(store: store) } + .alert($store.scope(state: \.alert, action: \.alert)) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { - viewModel.send(.tapAddUserCategory) + store.send(.tapAddUserCategory) } label: { Image(systemName: "plus") } @@ -88,7 +81,8 @@ struct TodoManageView: View { ToolbarItem(placement: .navigationBarTrailing) { Button { - onDismiss?(viewModel.state.preferences) + store.send(.tapDoneButton) + onDismiss?(store.preferences) } label: { Text(String(localized: "profile_done")) } @@ -97,27 +91,27 @@ struct TodoManageView: View { } .presentationDragIndicator(.visible) } +} + +private struct TodoManageCategorySheet: View { + let store: Store - private var categorySheet: some View { + var body: some View { NavigationStack { Form { Section { HStack(spacing: 8) { TextField( "", - text: $tmpText, - prompt: Text(viewModel.placeholder).foregroundStyle(.secondary) + text: Binding( + get: { store.category.name }, + set: { store.send(.setCategoryName($0)) } + ), + prompt: Text(store.placeholder).foregroundStyle(.secondary) ) .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) - .onAppear { - tmpText = currentCategoryName - } - .onChange(of: tmpText) { _, value in - viewModel.send(.setCategoryName(value)) - tmpText = currentCategoryName - } - Text(viewModel.categoryNameCountText) + Text(store.categoryNameCountText) .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() @@ -125,12 +119,15 @@ struct TodoManageView: View { } Section { - let color = Color(hexString: currentCategoryColorHex) ?? .randomValue + let color = Color(hexString: store.category.colorHex) ?? .randomValue ColorPicker(selection: Binding( get: { color }, - set: { viewModel.send(.setCategoryColor($0)) } + set: { + guard let hexValue = $0.hexValue else { return } + store.send(.setCategoryColor(hexValue)) + } ), supportsOpacity: false) { - Text(currentCategoryColorHex.isEmpty ? "#" : currentCategoryColorHex) + Text(store.category.colorHex.isEmpty ? "#" : store.category.colorHex) .overlay(alignment: .bottom) { Rectangle() .frame(height: 1) @@ -138,50 +135,28 @@ struct TodoManageView: View { } .foregroundStyle(color) .onTapGesture { - viewModel.send(.setRandomCategoryColor) + store.send(.tapRandomColorButton) } } .pickerStyle(.palette) } } - .navigationTitle(viewModel.navigationTitle) + .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button(String(localized: "common_close")) { - viewModel.send(.setShowSheet(false)) + store.send(.tapCloseButton) } } ToolbarItem(placement: .navigationBarTrailing) { - Button(viewModel.submitTitle) { - viewModel.send(.saveUserCategory) + Button(store.submitTitle) { + store.send(.tapSaveButton) } - .disabled(!viewModel.canSubmitUserCategory) + .disabled(!store.canSubmitUserCategory) } } } } - - private var currentCategoryName: String { - guard - let categoryItem = viewModel.state.category, - case .user(let userTodoCategory) = categoryItem.category - else { - return "" - } - - return userTodoCategory.name - } - - private var currentCategoryColorHex: String { - guard - let categoryItem = viewModel.state.category, - case .user(let userTodoCategory) = categoryItem.category - else { - return "" - } - - return userTodoCategory.colorHex - } } diff --git a/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift deleted file mode 100644 index 7dedcbeb..00000000 --- a/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift +++ /dev/null @@ -1,242 +0,0 @@ -// -// TodoManageViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/30/25. -// - -import SwiftUI -import DevLogDomain - -@Observable -final class TodoManageViewModel: StorePattern { - struct State: Equatable { - var preferences: [TodoCategoryItem] - var category: TodoCategoryItem? - var showSheet: Bool = false - var showAlert: Bool = false - } - - enum Action { - case tapAddUserCategory - case moveItem(from: IndexSet, target: Int) - case tapItem(TodoCategoryItem) - case tapEditUserCategory(TodoCategoryItem) - case tapDeleteUserCategory(TodoCategoryItem) - case confirmDeleteUserCategory - case setShowSheet(Bool) - case setShowAlert(Bool) - case setCategoryName(String) - case setCategoryColor(Color) - case setRandomCategoryColor - case saveUserCategory - } - - enum SideEffect { } - - private(set) var state: State - - var isEditing: Bool { - guard let categoryItem = state.category else { - return false - } - - return state.preferences.contains { $0.id == categoryItem.id } - } - - var navigationTitle: String { - isEditing - ? String(localized: "todo_manage_edit_category_title") - : String(localized: "todo_manage_add_category_title") - } - - var submitTitle: String { - isEditing - ? String(localized: "todo_manage_save") - : String(localized: "todo_add") - } - - var placeholder: String { - guard - let item = state.category, - case .user(let category) = item.category - else { - return String(localized: "todo_manage_name_placeholder") - } - - return category.name - } - - var categoryNameCountText: String { - guard - let item = state.category, - case .user(let category) = item.category - else { - return "0/20" - } - - return "\(category.name.count)/20" - } - - var canSubmitUserCategory: Bool { - guard - let item = state.category, - case .user(let category) = item.category - else { - return false - } - - let name = category.name.trimmingCharacters(in: .whitespacesAndNewlines) - if name.isEmpty { - return false - } - - if SystemTodoCategory.allCases.contains(where: { - $0.rawValue.caseInsensitiveCompare(name) == .orderedSame - }) { - return false - } - - if state.preferences.contains(where: { item in - guard case .user(let userCategory) = item.category, - userCategory.id != category.id else { - return false - } - - return userCategory.name.caseInsensitiveCompare(name) == .orderedSame - }) { - return false - } - - if let item = state.preferences.first(where: { $0.id == item.id }), - case .user(let originalCategory) = item.category { - let originalName = originalCategory.name.trimmingCharacters(in: .whitespacesAndNewlines) - if originalName == name && originalCategory.colorHex == category.colorHex { - return false - } - } - - return true - } - - init(_ preferences: [TodoCategoryItem]) { - self.state = State(preferences: preferences) - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - - switch action { - case .tapAddUserCategory: - guard let randomHexValue = Color.randomValue.hexValue else { - break - } - - state.category = TodoCategoryItem( - from: .user( - UserTodoCategory( - id: UUID().uuidString.lowercased(), - name: "", - colorHex: randomHexValue - ) - ) - ) - state.showSheet = true - case .moveItem(let from, let target): - state.preferences.move(fromOffsets: from, toOffset: target) - case .tapItem(let item): - if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { - state.preferences[index].isVisible.toggle() - } - case .tapEditUserCategory(let item): - guard item.isUserCategory else { - break - } - - state.category = item - state.showSheet = true - case .tapDeleteUserCategory(let item): - guard item.isUserCategory else { - break - } - - state.category = item - state.showAlert = true - case .confirmDeleteUserCategory: - guard let categoryItem = state.category else { - break - } - - if let index = state.preferences.firstIndex(where: { $0.id == categoryItem.id }) { - state.preferences.remove(at: index) - } - state.showAlert = false - state.category = nil - case .setShowSheet(let isPresented): - state.showSheet = isPresented - if !isPresented { - state.category = nil - } - case .setShowAlert(let isPresented): - state.showAlert = isPresented - if !isPresented { - state.category = nil - } - case .setCategoryName(let name): - guard var item = state.category, - case .user(var category) = item.category else { - break - } - - category.name = String(name.prefix(20)) - item.category = .user(category) - state.category = item - case .setCategoryColor(let color): - guard var item = state.category, - case .user(var category) = item.category, - let hexValue = color.hexValue else { - break - } - - category.colorHex = hexValue - item.category = .user(category) - state.category = item - case .setRandomCategoryColor: - guard var item = state.category, - case .user(var category) = item.category, - let randomHexValue = Color.randomValue.hexValue else { - break - } - - category.colorHex = randomHexValue - item.category = .user(category) - state.category = item - case .saveUserCategory: - guard var item = state.category, - case .user(let category) = item.category else { - break - } - - item.category = .user( - UserTodoCategory( - id: category.id, - name: category.name.trimmingCharacters(in: .whitespacesAndNewlines), - colorHex: category.colorHex - ) - ) - - if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { - item.isVisible = state.preferences[index].isVisible - state.preferences[index] = item - } else { - state.preferences.append(item) - } - - state.showSheet = false - state.category = nil - } - - if self.state != state { self.state = state } - return [] - } -} diff --git a/Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift new file mode 100644 index 00000000..df9e9f7c --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift @@ -0,0 +1,213 @@ +// +// TodoManageFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/11/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct TodoManageFeatureTests { + @Test("항목을 누르면 표시 여부가 전환된다") + func 항목을_누르면_표시_여부가_전환된다() { + let item = TodoCategoryItem(from: .system(.issue)) + let driver = TodoManageTestDriver(preferences: [item]) + + driver.tapItem(item) + + #expect(driver.preferences.first?.isVisible == false) + } + + @Test("항목을 이동하면 preferences 순서가 변경된다") + func 항목을_이동하면_preferences_순서가_변경된다() { + let issue = TodoCategoryItem(from: .system(.issue)) + let feature = TodoCategoryItem(from: .system(.feature)) + let driver = TodoManageTestDriver(preferences: [issue, feature]) + + driver.moveItem(from: IndexSet(integer: 0), target: 2) + + #expect(driver.preferences.map(\.id) == [feature.id, issue.id]) + } + + @Test("사용자 카테고리 추가를 누르면 카테고리 입력 시트 상태가 생성된다") + func 사용자_카테고리_추가를_누르면_카테고리_입력_시트_상태가_생성된다() { + let item = TodoCategoryItem(from: .system(.issue)) + let driver = TodoManageTestDriver(preferences: [item]) + + driver.tapAddUserCategory() + + #expect(driver.categorySheet?.category.name == "") + #expect(driver.categorySheet?.category.colorHex.count == 7) + #expect(driver.categorySheet?.preferences == [item]) + } + + @Test("카테고리 이름은 20자로 제한된다") + func 카테고리_이름은_20자로_제한된다() { + let driver = TodoManageTestDriver(preferences: []) + + driver.tapAddUserCategory() + driver.setCategoryName(String(repeating: "a", count: 25)) + + #expect(driver.categorySheet?.category.name == String(repeating: "a", count: 20)) + } + + @Test("새 사용자 카테고리를 저장하면 이름을 trim한 항목이 추가되고 시트가 닫힌다") + func 새_사용자_카테고리를_저장하면_이름을_trim한_항목이_추가되고_시트가_닫힌다() { + let driver = TodoManageTestDriver(preferences: []) + + driver.tapAddUserCategory() + let colorHex = driver.categorySheet?.category.colorHex + driver.setCategoryName(" Custom ") + driver.tapSaveButton() + + #expect(driver.preferences.count == 1) + #expect(driver.userCategory(at: 0)?.name == "Custom") + #expect(driver.userCategory(at: 0)?.colorHex == colorHex) + #expect(driver.categorySheet == nil) + } + + @Test("기존 사용자 카테고리를 저장하면 표시 여부를 유지한 채 항목을 교체한다") + func 기존_사용자_카테고리를_저장하면_표시_여부를_유지한_채_항목을_교체한다() { + let item = TodoCategoryItem( + from: .user( + UserTodoCategory( + id: "custom", + name: "Old", + colorHex: "#111111" + ) + ), + isVisible: false + ) + let driver = TodoManageTestDriver(preferences: [item]) + + driver.tapEditUserCategory(item) + driver.setCategoryName("New") + driver.setCategoryColor("#222222") + driver.tapSaveButton() + + #expect(driver.preferences.count == 1) + #expect(driver.preferences.first?.isVisible == false) + #expect(driver.userCategory(at: 0)?.name == "New") + #expect(driver.userCategory(at: 0)?.colorHex == "#222222") + #expect(driver.categorySheet == nil) + } + + @Test("사용자 카테고리 삭제를 확인하면 항목이 제거된다") + func 사용자_카테고리_삭제를_확인하면_항목이_제거된다() { + let issue = TodoCategoryItem(from: .system(.issue)) + let item = TodoCategoryItem( + from: .user( + UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + ) + ) + let driver = TodoManageTestDriver(preferences: [issue, item]) + + driver.tapDeleteUserCategory(item) + driver.confirmDeleteUserCategory(item) + + #expect(driver.preferences.map(\.id) == [SystemTodoCategory.issue.rawValue]) + #expect(driver.alert == nil) + } + + @Test("삭제 알림을 닫으면 알림 상태가 초기화된다") + func 삭제_알림을_닫으면_알림_상태가_초기화된다() { + let item = TodoCategoryItem( + from: .user( + UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + ) + ) + let driver = TodoManageTestDriver(preferences: [item]) + + driver.tapDeleteUserCategory(item) + driver.dismissAlert() + + #expect(driver.preferences == [item]) + #expect(driver.alert == nil) + } +} + +@MainActor +private struct TodoManageTestDriver { + private let feature: StoreOf + + var preferences: [TodoCategoryItem] { + feature.state.preferences + } + + var categorySheet: TodoManageFeature.CategorySheetState? { + feature.state.categorySheet + } + + var alert: AlertState? { + feature.state.alert + } + + init(preferences: [TodoCategoryItem]) { + feature = Store( + initialState: TodoManageFeature.State(preferences: preferences) + ) { + TodoManageFeature() + } + } + + func tapItem(_ item: TodoCategoryItem) { + feature.send(.tapItem(item)) + } + + func moveItem(from source: IndexSet, target: Int) { + feature.send(.moveItem(from: source, target: target)) + } + + func tapAddUserCategory() { + feature.send(.tapAddUserCategory) + } + + func tapEditUserCategory(_ item: TodoCategoryItem) { + feature.send(.tapEditUserCategory(item)) + } + + func tapDeleteUserCategory(_ item: TodoCategoryItem) { + feature.send(.tapDeleteUserCategory(item)) + } + + func setCategoryName(_ name: String) { + feature.send(.categorySheet(.presented(.setCategoryName(name)))) + } + + func setCategoryColor(_ colorHex: String) { + feature.send(.categorySheet(.presented(.setCategoryColor(colorHex)))) + } + + func tapSaveButton() { + feature.send(.categorySheet(.presented(.tapSaveButton))) + } + + func confirmDeleteUserCategory(_ item: TodoCategoryItem) { + feature.send(.alert(.presented(.confirmDeleteUserCategory(item)))) + } + + func dismissAlert() { + feature.send(.alert(.dismiss)) + } + + func userCategory(at index: Int) -> UserTodoCategory? { + guard case .user(let category) = preferences[index].category else { + return nil + } + + return category + } +} From ba3176ef5eb2657ff2b02db9239e3d347840b34e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:53:26 +0900 Subject: [PATCH 3/5] refactor: TodoManage -> CategoryManage --- .../CategoryManageFeature.swift} | 6 ++-- .../CategoryManageView.swift} | 16 +++++----- .../Sources/Home/Home/HomeView.swift | 2 +- ...swift => CategoryManageFeatureTests.swift} | 32 +++++++++---------- 4 files changed, 28 insertions(+), 28 deletions(-) rename Application/DevLogPresentation/Sources/Home/{TodoManageFeature.swift => CategoryManage/CategoryManageFeature.swift} (98%) rename Application/DevLogPresentation/Sources/Home/{TodoManageView.swift => CategoryManage/CategoryManageView.swift} (92%) rename Application/DevLogPresentation/Tests/Home/{TodoManageFeatureTests.swift => CategoryManageFeatureTests.swift} (86%) diff --git a/Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift b/Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageFeature.swift similarity index 98% rename from Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift rename to Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageFeature.swift index 628aec6d..3f49cb0e 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoManageFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageFeature.swift @@ -1,5 +1,5 @@ // -// TodoManageFeature.swift +// CategoryManageFeature.swift // DevLogPresentation // // Created by opfic on 6/11/26. @@ -10,7 +10,7 @@ import DevLogDomain import SwiftUI @Reducer -struct TodoManageFeature { +struct CategoryManageFeature { @ObservableState struct State: Equatable { var preferences: [TodoCategoryItem] @@ -182,7 +182,7 @@ struct TodoManageFeature { } } -private extension TodoManageFeature { +private extension CategoryManageFeature { func deleteAlertState(for item: TodoCategoryItem) -> AlertState { AlertState { TextState(String(localized: "todo_manage_delete_category_title")) diff --git a/Application/DevLogPresentation/Sources/Home/TodoManageView.swift b/Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageView.swift similarity index 92% rename from Application/DevLogPresentation/Sources/Home/TodoManageView.swift rename to Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageView.swift index 05bd1e5c..076128f1 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoManageView.swift +++ b/Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageView.swift @@ -1,5 +1,5 @@ // -// TodoManageView.swift +// CategoryManageView.swift // DevLogPresentation // // Created by opfic on 6/16/25. @@ -9,8 +9,8 @@ import SwiftUI import ComposableArchitecture import DevLogDomain -struct TodoManageView: View { - @State private var store: StoreOf +struct CategoryManageView: View { + @State private var store: StoreOf var onDismiss: (([TodoCategoryItem]) -> Void)? init( @@ -18,9 +18,9 @@ struct TodoManageView: View { onDismiss: (([TodoCategoryItem]) -> Void)? ) { self._store = State(initialValue: Store( - initialState: TodoManageFeature.State(preferences: preferences) + initialState: CategoryManageFeature.State(preferences: preferences) ) { - TodoManageFeature() + CategoryManageFeature() }) self.onDismiss = onDismiss } @@ -67,7 +67,7 @@ struct TodoManageView: View { .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() .sheet(item: $store.scope(state: \.categorySheet, action: \.categorySheet)) { store in - TodoManageCategorySheet(store: store) + CategoryManageSheet(store: store) } .alert($store.scope(state: \.alert, action: \.alert)) .toolbar { @@ -93,8 +93,8 @@ struct TodoManageView: View { } } -private struct TodoManageCategorySheet: View { - let store: Store +private struct CategoryManageSheet: View { + let store: Store var body: some View { NavigationStack { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index d947c98d..cd39134e 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -28,7 +28,7 @@ struct HomeView: View { get: { coordinator.viewModel.state.reorderTodo }, set: { coordinator.viewModel.send(.setPresentation(.reorderTodo, $0)) } )) { - TodoManageView( + CategoryManageView( preferences: coordinator.viewModel.state.preferences, onDismiss: { array in coordinator.viewModel.send(.setPresentation(.reorderTodo, false)) diff --git a/Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift similarity index 86% rename from Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift rename to Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift index df9e9f7c..31409206 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoManageFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/CategoryManageFeatureTests.swift @@ -1,5 +1,5 @@ // -// TodoManageFeatureTests.swift +// CategoryManageFeatureTests.swift // DevLogPresentationTests // // Created by opfic on 6/11/26. @@ -12,11 +12,11 @@ import DevLogDomain @testable import DevLogPresentation @MainActor -struct TodoManageFeatureTests { +struct CategoryManageFeatureTests { @Test("항목을 누르면 표시 여부가 전환된다") func 항목을_누르면_표시_여부가_전환된다() { let item = TodoCategoryItem(from: .system(.issue)) - let driver = TodoManageTestDriver(preferences: [item]) + let driver = CategoryManageTestDriver(preferences: [item]) driver.tapItem(item) @@ -27,7 +27,7 @@ struct TodoManageFeatureTests { func 항목을_이동하면_preferences_순서가_변경된다() { let issue = TodoCategoryItem(from: .system(.issue)) let feature = TodoCategoryItem(from: .system(.feature)) - let driver = TodoManageTestDriver(preferences: [issue, feature]) + let driver = CategoryManageTestDriver(preferences: [issue, feature]) driver.moveItem(from: IndexSet(integer: 0), target: 2) @@ -37,7 +37,7 @@ struct TodoManageFeatureTests { @Test("사용자 카테고리 추가를 누르면 카테고리 입력 시트 상태가 생성된다") func 사용자_카테고리_추가를_누르면_카테고리_입력_시트_상태가_생성된다() { let item = TodoCategoryItem(from: .system(.issue)) - let driver = TodoManageTestDriver(preferences: [item]) + let driver = CategoryManageTestDriver(preferences: [item]) driver.tapAddUserCategory() @@ -48,7 +48,7 @@ struct TodoManageFeatureTests { @Test("카테고리 이름은 20자로 제한된다") func 카테고리_이름은_20자로_제한된다() { - let driver = TodoManageTestDriver(preferences: []) + let driver = CategoryManageTestDriver(preferences: []) driver.tapAddUserCategory() driver.setCategoryName(String(repeating: "a", count: 25)) @@ -58,7 +58,7 @@ struct TodoManageFeatureTests { @Test("새 사용자 카테고리를 저장하면 이름을 trim한 항목이 추가되고 시트가 닫힌다") func 새_사용자_카테고리를_저장하면_이름을_trim한_항목이_추가되고_시트가_닫힌다() { - let driver = TodoManageTestDriver(preferences: []) + let driver = CategoryManageTestDriver(preferences: []) driver.tapAddUserCategory() let colorHex = driver.categorySheet?.category.colorHex @@ -83,7 +83,7 @@ struct TodoManageFeatureTests { ), isVisible: false ) - let driver = TodoManageTestDriver(preferences: [item]) + let driver = CategoryManageTestDriver(preferences: [item]) driver.tapEditUserCategory(item) driver.setCategoryName("New") @@ -109,7 +109,7 @@ struct TodoManageFeatureTests { ) ) ) - let driver = TodoManageTestDriver(preferences: [issue, item]) + let driver = CategoryManageTestDriver(preferences: [issue, item]) driver.tapDeleteUserCategory(item) driver.confirmDeleteUserCategory(item) @@ -129,7 +129,7 @@ struct TodoManageFeatureTests { ) ) ) - let driver = TodoManageTestDriver(preferences: [item]) + let driver = CategoryManageTestDriver(preferences: [item]) driver.tapDeleteUserCategory(item) driver.dismissAlert() @@ -140,26 +140,26 @@ struct TodoManageFeatureTests { } @MainActor -private struct TodoManageTestDriver { - private let feature: StoreOf +private struct CategoryManageTestDriver { + private let feature: StoreOf var preferences: [TodoCategoryItem] { feature.state.preferences } - var categorySheet: TodoManageFeature.CategorySheetState? { + var categorySheet: CategoryManageFeature.CategorySheetState? { feature.state.categorySheet } - var alert: AlertState? { + var alert: AlertState? { feature.state.alert } init(preferences: [TodoCategoryItem]) { feature = Store( - initialState: TodoManageFeature.State(preferences: preferences) + initialState: CategoryManageFeature.State(preferences: preferences) ) { - TodoManageFeature() + CategoryManageFeature() } } From cd00ff9a45d7ec05b0c3a7ee86670342ba22656f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:55:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/{ => Detail}/TodoDetailView.swift | 0 .../Sources/Home/{ => Detail}/TodoDetailViewModel.swift | 0 .../Sources/Home/{ => Editor}/TodoEditorView.swift | 0 .../Sources/Home/{ => Editor}/TodoEditorViewModel.swift | 0 .../Sources/Home/{ => Editor}/TodoEditorWindowEvent.swift | 0 .../Sources/Home/{ => Editor}/TodoEditorWindowValue.swift | 0 .../Sources/Home/{ => Editor}/TodoEditorWindowView.swift | 0 .../DevLogPresentation/Sources/Home/{ => List}/TodoListView.swift | 0 .../Sources/Home/{ => List}/TodoListViewModel.swift | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename Application/DevLogPresentation/Sources/Home/{ => Detail}/TodoDetailView.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => Detail}/TodoDetailViewModel.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => Editor}/TodoEditorView.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => Editor}/TodoEditorViewModel.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => Editor}/TodoEditorWindowEvent.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => Editor}/TodoEditorWindowValue.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => Editor}/TodoEditorWindowView.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => List}/TodoListView.swift (100%) rename Application/DevLogPresentation/Sources/Home/{ => List}/TodoListViewModel.swift (100%) diff --git a/Application/DevLogPresentation/Sources/Home/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoDetailView.swift rename to Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoDetailViewModel.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailViewModel.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoDetailViewModel.swift rename to Application/DevLogPresentation/Sources/Home/Detail/TodoDetailViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoEditorView.swift rename to Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoEditorViewModel.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoEditorViewModel.swift rename to Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoEditorWindowEvent.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowEvent.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoEditorWindowEvent.swift rename to Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowEvent.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoEditorWindowValue.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowValue.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoEditorWindowValue.swift rename to Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowValue.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoEditorWindowView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowView.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoEditorWindowView.swift rename to Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowView.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoListView.swift rename to Application/DevLogPresentation/Sources/Home/List/TodoListView.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListViewModel.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift rename to Application/DevLogPresentation/Sources/Home/List/TodoListViewModel.swift From 3f5969c2237f52b5d37ec2ca1c4956017fe27b1c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:59:23 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{Home/Editor => WindowGroup}/TodoEditorWindowEvent.swift | 0 .../{Home/Editor => WindowGroup}/TodoEditorWindowValue.swift | 0 .../{Home/Editor => WindowGroup}/TodoEditorWindowView.swift | 0 .../Sources/{Home => WindowGroup}/TodoWindowCoordinator.swift | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename Application/DevLogPresentation/Sources/{Home/Editor => WindowGroup}/TodoEditorWindowEvent.swift (100%) rename Application/DevLogPresentation/Sources/{Home/Editor => WindowGroup}/TodoEditorWindowValue.swift (100%) rename Application/DevLogPresentation/Sources/{Home/Editor => WindowGroup}/TodoEditorWindowView.swift (100%) rename Application/DevLogPresentation/Sources/{Home => WindowGroup}/TodoWindowCoordinator.swift (100%) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowEvent.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowEvent.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowEvent.swift rename to Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowEvent.swift diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowValue.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowValue.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowValue.swift rename to Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowValue.swift diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowView.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/Editor/TodoEditorWindowView.swift rename to Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift diff --git a/Application/DevLogPresentation/Sources/Home/TodoWindowCoordinator.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift similarity index 100% rename from Application/DevLogPresentation/Sources/Home/TodoWindowCoordinator.swift rename to Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift