From 53d4fd9647c7b9f5f176d590b22999d40873f8ec Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 5 Oct 2026 13:28:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B4=EB=A6=84=20=EC=A4=91=EB=B3=B5=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EC=99=80=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Category/CategoryManageFeature.swift | 21 ++++- .../Home/CategoryManageFeatureTests.swift | 85 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Category/CategoryManageFeature.swift b/Application/Presentation/HomeTab/Sources/Category/CategoryManageFeature.swift index 8a41b306..41cfb9e2 100644 --- a/Application/Presentation/HomeTab/Sources/Category/CategoryManageFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Category/CategoryManageFeature.swift @@ -42,7 +42,7 @@ struct CategoryManageFeature { var categoryNameCountText: String { "\(category.name.count)/20" } - var canSubmitUserCategory: Bool { + var isDuplicatedName: Bool { let name = category.name.trimmingCharacters(in: .whitespacesAndNewlines) if name.isEmpty { return false @@ -51,7 +51,7 @@ struct CategoryManageFeature { if SystemTodoCategory.allCases.contains(where: { $0.rawValue.caseInsensitiveCompare(name) == .orderedSame }) { - return false + return true } if preferences.contains(where: { item in @@ -61,6 +61,23 @@ struct CategoryManageFeature { return userCategory.name.caseInsensitiveCompare(name) == .orderedSame }) { + return true + } + + return false + } + var nameMessage: String { + isDuplicatedName + ? String(localized: "todo_manage_name_duplicate", bundle: PresentationResources.bundle) + : String(localized: "todo_manage_name_hint", bundle: PresentationResources.bundle) + } + var canSubmitUserCategory: Bool { + let name = category.name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + return false + } + + if isDuplicatedName { return false } diff --git a/Application/Presentation/HomeTab/Tests/Home/CategoryManageFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/CategoryManageFeatureTests.swift index 6512ed90..0b192f96 100644 --- a/Application/Presentation/HomeTab/Tests/Home/CategoryManageFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/CategoryManageFeatureTests.swift @@ -56,6 +56,91 @@ struct CategoryManageFeatureTests { #expect(driver.categorySheet?.category.name == String(repeating: "a", count: 20)) } + @Test("시스템 카테고리 이름과 중복되면 저장할 수 없다", arguments: SystemTodoCategory.allCases.map(\.rawValue)) + func 시스템_카테고리_이름과_중복되면_저장할_수_없다(name: String) { + let driver = CategoryManageTestDriver(preferences: []) + + driver.tapAddUserCategory() + driver.setCategoryName(" \(name.uppercased()) ") + + #expect(driver.categorySheet?.isDuplicatedName == true) + #expect(driver.categorySheet?.canSubmitUserCategory == false) + #expect(driver.categorySheet?.nameMessage == String( + localized: "todo_manage_name_duplicate", bundle: PresentationResources.bundle + )) + } + + @Test("다른 사용자 카테고리 이름은 대소문자를 구분하지 않고 중복을 검사한다") + func 다른_사용자_카테고리_이름은_대소문자를_구분하지_않고_중복을_검사한다() { + let category = UserTodoCategory(id: "custom", name: "Custom", colorHex: "#111111") + let driver = CategoryManageTestDriver(preferences: [TodoCategoryItem(from: .user(category))]) + + driver.tapAddUserCategory() + driver.setCategoryName(" cUsToM ") + + #expect(driver.categorySheet?.category.name == " cUsToM ") + #expect(driver.categorySheet?.isDuplicatedName == true) + #expect(driver.categorySheet?.canSubmitUserCategory == false) + #expect(driver.categorySheet?.nameMessage == String( + localized: "todo_manage_name_duplicate", bundle: PresentationResources.bundle + )) + + driver.setCategoryName("Unique") + + #expect(driver.categorySheet?.isDuplicatedName == false) + #expect(driver.categorySheet?.canSubmitUserCategory == true) + #expect(driver.categorySheet?.nameMessage == String( + localized: "todo_manage_name_hint", bundle: PresentationResources.bundle + )) + } + + @Test("수정 중인 카테고리의 원래 이름은 중복이 아니다") + func 수정_중인_카테고리의_원래_이름은_중복이_아니다() { + let category = UserTodoCategory(id: "custom", name: "Custom", colorHex: "#111111") + let item = TodoCategoryItem(from: .user(category)) + let driver = CategoryManageTestDriver(preferences: [item]) + + driver.tapEditUserCategory(item) + + #expect(driver.categorySheet?.isDuplicatedName == false) + #expect(driver.categorySheet?.nameMessage == String( + localized: "todo_manage_name_hint", bundle: PresentationResources.bundle + )) + } + + @Test("수정 내용이 없으면 저장할 수 없고 색상만 바꾸면 저장할 수 있다") + func 수정_내용이_없으면_저장할_수_없고_색상만_바꾸면_저장할_수_있다() { + let category = UserTodoCategory(id: "custom", name: "Custom", colorHex: "#111111") + let item = TodoCategoryItem(from: .user(category)) + let driver = CategoryManageTestDriver(preferences: [item]) + + driver.tapEditUserCategory(item) + + #expect(driver.categorySheet?.canSubmitUserCategory == false) + + driver.setCategoryColor("#222222") + + #expect(driver.categorySheet?.canSubmitUserCategory == true) + #expect(driver.categorySheet?.isDuplicatedName == false) + #expect(driver.categorySheet?.nameMessage == String( + localized: "todo_manage_name_hint", bundle: PresentationResources.bundle + )) + } + + @Test("빈 이름은 중복 오류 대신 안내 문구를 표시한다", arguments: ["", " ", "\n"]) + func 빈_이름은_중복_오류_대신_안내_문구를_표시한다(name: String) { + let driver = CategoryManageTestDriver(preferences: []) + + driver.tapAddUserCategory() + driver.setCategoryName(name) + + #expect(driver.categorySheet?.isDuplicatedName == false) + #expect(driver.categorySheet?.canSubmitUserCategory == false) + #expect(driver.categorySheet?.nameMessage == String( + localized: "todo_manage_name_hint", bundle: PresentationResources.bundle + )) + } + @Test("새 사용자 카테고리를 저장하면 이름을 trim한 항목이 추가되고 시트가 닫힌다") func 새_사용자_카테고리를_저장하면_이름을_trim한_항목이_추가되고_시트가_닫힌다() { let driver = CategoryManageTestDriver(preferences: []) From 90aecfa3b6b6facb1214873f620f915f43f0733d Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 5 Oct 2026 13:28:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?ui:=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=C2=B7=EC=88=98=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20Figma=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Category/CategoryManageView.swift | 258 +++++++++++++----- .../Resources/Localizable.xcstrings | 85 ++++++ 2 files changed, 282 insertions(+), 61 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift b/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift index 69fd1cb9..c454fe76 100644 --- a/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift +++ b/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift @@ -26,23 +26,8 @@ struct CategoryManageView: View { } .margins(.horizontal, 16) .margins(.vertical, 8), - footer: UIHostingConfiguration { - Button { - store.send(.tapAddUserCategory) - } label: { - Label( - String(localized: "todo_manage_add_category_title", bundle: PresentationResources.bundle), - systemImage: "plus.circle.fill" - ) - .font(.headline) - .foregroundStyle(Color.onPrimaryContainer) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - } - .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .primaryContainer) - } - .margins(.horizontal, 16) - .margins(.vertical, 24), + footer: UIHostingConfiguration { EmptyView() } + .margins(.vertical, 8), row: { item in UIHostingConfiguration { CategoryManageRow( @@ -60,8 +45,25 @@ struct CategoryManageView: View { } ) } - .ignoresSafeArea(edges: .bottom) .safeAreaInset(edge: .top, spacing: 0) { toolBar } + .safeAreaInset(edge: .bottom, spacing: 0) { + Button { + store.send(.tapAddUserCategory) + } label: { + Label( + String(localized: "todo_manage_add_category_title", bundle: PresentationResources.bundle), + systemImage: "plus.circle.fill" + ) + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } .background(Color.appBackground.ignoresSafeArea()) .sheet( item: $store.scope(state: \.categorySheet, action: \.categorySheet) @@ -110,7 +112,7 @@ private struct CategoryManageRow: View { var body: some View { if item.isUserCategory { - content + CategoryManageRowContent(item: item, onToggle: onToggle) .background(Color.surface) .itemActions { ItemActionButton( @@ -126,11 +128,16 @@ private struct CategoryManageRow: View { ) } } else { - content + CategoryManageRowContent(item: item, onToggle: onToggle) } } +} + +private struct CategoryManageRowContent: View { + let item: TodoCategoryItem + let onToggle: () -> Void - private var content: some View { + var body: some View { HStack(spacing: 12) { Image(systemName: item.symbolName) .font(.headline) @@ -154,60 +161,189 @@ private struct CategoryManageRow: View { } } +private let categoryColorHexValues = [ + "#FF3B30", "#FF9500", "#34C759", "#1EA0A6", "#007AFF", "#AF52DE", "#FF2D55", "#8E8E93" +] + private struct CategoryManageSheet: View { @Bindable var store: Store var body: some View { - NavigationStack { - Form { - Section { - HStack(spacing: 8) { - TextField( - "", - text: $store.category.name, - prompt: Text(store.placeholder).foregroundStyle(.secondary) - ) - .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) - - Text(store.categoryNameCountText) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() + ScrollView { + LazyVStack(spacing: 24) { + nameCard + colorCard + previewCard + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .contentMargins(.top, 16, for: .scrollContent) + .safeAreaInset(edge: .top, spacing: 0) { toolBar } + .safeAreaInset(edge: .bottom, spacing: 0) { + Button { + store.send(.tapSaveButton) + } label: { + Text(store.submitTitle) + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + .disabled(!store.canSubmitUserCategory) + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } + .background(Color.appBackground.ignoresSafeArea()) + .presentationDragIndicator(.visible) + } + + private var toolBar: some View { + ZStack { + Text(store.navigationTitle) + .font(.headline) + HStack { + Button { + store.send(.tapCloseButton) + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + } else { + Text(String(localized: "common_close", bundle: PresentationResources.bundle)) } } + .topBarButtonStyle() - Section { - ColorPicker(selection: $store.category.colorHex.colorValue, supportsOpacity: false) { - Text(store.category.colorHex.isEmpty ? "#" : store.category.colorHex) - .overlay(alignment: .bottom) { - Rectangle() - .frame(height: 1) - .offset(y: 1) - } - .foregroundStyle(store.category.colorHex.colorValue) - .onTapGesture { - store.send(.tapRandomColorButton) - } - } - .pickerStyle(.palette) + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var nameCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "todo_manage_name_placeholder", bundle: PresentationResources.bundle)) + .font(.headline) + .padding(.horizontal, 16) + + HStack(spacing: 8) { + TextField( + "", + text: $store.category.name, + prompt: Text(store.placeholder).foregroundStyle(.secondary) + ) + .font(.body) + + Text(store.categoryNameCountText) + .font(.footnote) + .foregroundStyle(Color.textSecondary) + .monospacedDigit() + } + .padding() + .background(Color.surface, in: .rect(cornerRadius: 16)) + .tint(Color.accent) + + Text(store.nameMessage) + .font(.footnote) + .foregroundStyle(store.isDuplicatedName ? Color.danger : Color.textSecondary) + .padding(.horizontal, 16) + } + } + + private var colorCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(String(localized: "todo_manage_color", bundle: PresentationResources.bundle)) + .font(.headline) + + Spacer() + + Button { + store.send(.tapRandomColorButton) + } label: { + Label( + String(localized: "todo_manage_random_color", bundle: PresentationResources.bundle), + systemImage: "shuffle" + ) + .font(.subheadline) + .foregroundStyle(Color.accent) } + .buttonStyle(.plain) } - .navigationTitle(store.navigationTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(String(localized: "common_close", bundle: PresentationResources.bundle)) { - store.send(.tapCloseButton) + .padding(.horizontal, 16) + + VStack(spacing: 16) { + Image(systemName: store.todoCategoryItem.symbolName) + .font(.largeTitle) + .frame(width: 88, height: 88) + .iconStyle(color: store.todoCategoryItem.color, in: Circle()) + + HStack(spacing: 4) { + ForEach(categoryColorHexValues, id: \.self) { colorHex in + let color = Color(hexString: colorHex) ?? .gray + let isSelected = store.category.colorHex.caseInsensitiveCompare(colorHex) == .orderedSame + + Button { + store.send(.binding(.set(\.category.colorHex, colorHex))) + } label: { + Circle() + .fill(color) + .padding(3) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + Circle() + .strokeBorder(color, lineWidth: 2) + } + } + .aspectRatio(1, contentMode: .fit) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) } + + ColorPicker( + store.category.colorHex.isEmpty ? "#" : store.category.colorHex, + selection: $store.category.colorHex.colorValue, + supportsOpacity: false + ) + .labelsHidden() + .frame(maxWidth: .infinity) } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(Color.surface, in: .rect(cornerRadius: 16)) + } + } - ToolbarItem(placement: .navigationBarTrailing) { - Button(store.submitTitle) { - store.send(.tapSaveButton) - } - .disabled(!store.canSubmitUserCategory) + private var previewCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "todo_preview", bundle: PresentationResources.bundle)) + .font(.headline) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "todo_manage_preview_description", bundle: PresentationResources.bundle)) + .font(.footnote) + .foregroundStyle(Color.textSecondary) + + HStack(spacing: 12) { + CategoryManageRowContent(item: store.todoCategoryItem, onToggle: {}) + Image(systemName: "line.3.horizontal") + .foregroundStyle(Color.textTertiary) } + .allowsHitTesting(false) } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.surface, in: .rect(cornerRadius: 16)) } } } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index fdc77602..32893f59 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -5379,6 +5379,23 @@ } } }, + "todo_manage_color" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상" + } + } + } + }, "todo_manage_delete_category_message" : { "extractionState" : "manual", "localizations" : { @@ -5447,6 +5464,40 @@ } } }, + "todo_manage_name_duplicate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This category name is already in use." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미 사용 중인 카테고리 이름이에요." + } + } + } + }, + "todo_manage_name_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a category name." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카테고리 이름을 입력하세요." + } + } + } + }, "todo_manage_name_placeholder" : { "extractionState" : "manual", "localizations" : { @@ -5464,6 +5515,40 @@ } } }, + "todo_manage_preview_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is how the category will appear." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카테고리가 이렇게 표시돼요." + } + } + } + }, + "todo_manage_random_color" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Random Color" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "무작위 색상" + } + } + } + }, "todo_manage_save" : { "extractionState" : "manual", "localizations" : {