From b57980163301a9d785f3664f42fe71b4c067c6ce Mon Sep 17 00:00:00 2001 From: kangddong Date: Sun, 15 Mar 2026 01:23:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20TextField=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?DoriInputField=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DoriInputField: TCA 기반 금액/텍스트 입력 공용 컴포넌트 추가 (최대값 cap, 천단위 콤마, 클리어 버튼) - DoriKeyboardDismissModifier: 빈 영역 탭으로 키보드 dismiss 지원 - AddDori/EditDori: 기존 TextField → DoriInputField로 교체 및 입력 검증 강화 - TypoStyle: Equatable 준수 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../DoriKeyboardDismissModifier.swift | 50 ++ .../InputField/DoriInputField.swift | 537 ++++++++++++++++++ .../Sources/DoriTextField.swift | 34 +- .../Segment/DoriSegmentGridWithMemo.swift | 11 +- .../Sources/Typography/TypoStyle.swift | 2 +- .../Tests/InputFieldFeatureTests.swift | 61 ++ Projects/Core/Project.swift | 13 +- .../AddDori/Sources/AddDoriFeature.swift | 41 +- .../Feature/AddDori/Sources/AddDoriView.swift | 1 + .../Views/Page2RelationEventView.swift | 3 +- .../Sources/Views/Page3AmountDateView.swift | 15 +- .../PartnerDoriDetail/EditDoriFeature.swift | 41 +- .../PartnerDoriDetail/EditDoriView.swift | 153 ++++- .../Tests/EditDoriMemoFieldTests.swift | 146 +++++ Projects/Feature/Project.swift | 6 + textfield_testing_checklist.md | 205 +++++++ 16 files changed, 1236 insertions(+), 83 deletions(-) create mode 100644 Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift create mode 100644 Projects/Core/DoriDesignSystem/Sources/Components/InputField/DoriInputField.swift create mode 100644 Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift create mode 100644 Projects/Feature/History/Tests/EditDoriMemoFieldTests.swift create mode 100644 textfield_testing_checklist.md diff --git a/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift b/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift new file mode 100644 index 0000000..786cfe7 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift @@ -0,0 +1,50 @@ +// +// DoriKeyboardDismissModifier.swift +// Dori-iOS +// +// Created by 강동영 on 2/27/26. +// + +import SwiftUI + +/// 빈 영역 탭으로 키보드를 dismiss하는 ViewModifier +/// +/// numberPad 등 Return 키가 없는 키보드 타입에서 특히 유용합니다. +/// `.contentShape(Rectangle())`를 사용하여 빈 영역(Spacer 등)도 탭 인식이 가능합니다. +/// +/// ## 사용법 +/// ```swift +/// VStack { +/// TextField("금액", text: $amount) +/// .keyboardType(.numberPad) +/// Spacer() +/// } +/// .doriKeyboardDismissable() // 빈 영역 탭 시 키보드 dismiss +/// ``` +/// +/// ## 주의사항 +/// - 버튼이나 리스트 아이템 등 더 구체적인 gesture가 있는 경우, 해당 gesture가 우선 처리됩니다. +/// - overlay나 sheet 등의 상위 레이어와는 충돌하지 않습니다. +public struct DoriKeyboardDismissModifier: ViewModifier { + public func body(content: Content) -> some View { + content + .contentShape(Rectangle()) // 빈 영역도 탭 인식 + .onTapGesture { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + } +} + +public extension View { + /// 빈 영역 탭으로 키보드를 dismiss할 수 있게 만듭니다. + /// + /// numberPad 등 Return 키가 없는 키보드에서 유용합니다. + func doriKeyboardDismissable() -> some View { + modifier(DoriKeyboardDismissModifier()) + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/Components/InputField/DoriInputField.swift b/Projects/Core/DoriDesignSystem/Sources/Components/InputField/DoriInputField.swift new file mode 100644 index 0000000..61c8e48 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Components/InputField/DoriInputField.swift @@ -0,0 +1,537 @@ +// +// DoriInputField.swift +// Dori-iOS +// +// Created by 강동영 on 2/26/26. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: - 케이스 타입 정의 + +/// (A) Variant 축: 입력 필드의 용도/타입 +public enum InputFieldVariant: Equatable, Sendable { + /// 금액 입력 (숫자만, 천단위 콤마 포맷팅) + case amount(maxAmount: Int? = nil) + /// 일반 텍스트 입력 + case text(maxLength: Int? = nil) + + public var isAmount: Bool { + if case .amount = self { return true } + return false + } + + public var maxAmount: Int? { + if case .amount(let max) = self { return max } + return nil + } + + public var maxLength: Int? { + if case .text(let max) = self { return max } + return nil + } +} + +/// (B) State 축: 입력 필드의 표현 상태 +public enum InputFieldState: Equatable, Hashable, Sendable { + case normal + case error(message: String) + + public var isError: Bool { + if case .error = self { return true } + return false + } + + public var errorMessage: String? { + if case .error(let message) = self { return message } + return nil + } +} + +/// (C) Trailing 요소 축: 우측 액세서리 +public enum InputFieldTrailing: Equatable, Sendable { + case none + case unitOnly(text: String) + case clearOnly + case unitAndClear(unitText: String) + + public var hasUnit: Bool { + switch self { + case .unitOnly, .unitAndClear: return true + case .none, .clearOnly: return false + } + } + + public var hasClear: Bool { + switch self { + case .clearOnly, .unitAndClear: return true + case .none, .unitOnly: return false + } + } + + public var unitText: String? { + switch self { + case .unitOnly(let text), .unitAndClear(let text): return text + case .none, .clearOnly: return nil + } + } +} + +// MARK: - 스타일 토큰 + +/// 입력 필드 스타일 정의 +public struct InputFieldStyle: Equatable, Sendable { + let height: CGFloat + let cornerRadius: CGFloat + let horizontalPadding: CGFloat + let spacing: CGFloat + + // Colors + let normalBorderColor: Color + let backgroundColor: Color + + // Text + let textColor: Color + let errorTextColor: Color + let placeholderColor: Color + let font: TypoSemantic + + // Unit Label + let unitColor: Color + let unitFont: TypoSemantic + + // Error Message + let errorMessageColor: Color + let errorMessageFont: TypoSemantic + + public static let `default` = InputFieldStyle( + height: 52, + cornerRadius: 10, + horizontalPadding: 16, + spacing: 8, + normalBorderColor: UIAsset.Colors.grey300.color, + backgroundColor: UIAsset.Colors.doriWhite.color, + textColor: UIAsset.Colors.doriBlack.color, + errorTextColor: Color(hex: "FF3B30"), + placeholderColor: UIAsset.Colors.grey400.color, + font: TypoSemantic.body(.sb3), + unitColor: UIAsset.Colors.grey500.color, + unitFont: TypoSemantic.body(.r3), + errorMessageColor: Color(hex: "FF3B30"), + errorMessageFont: TypoSemantic.body(.r3), + ) +} + +// MARK: - TCA Reducer + +@Reducer +public struct InputFieldFeature { + @ObservableState + public struct State: Equatable, Sendable { + public var text: String + public var variant: InputFieldVariant + public var state: InputFieldState + public var trailing: InputFieldTrailing + public var placeholder: String + public var style: InputFieldStyle + + /// 표시용 텍스트 (천단위 콤마 포함) + var displayText: String { + guard variant.isAmount, !text.isEmpty else { return text } + return formatAmount(text) + } + + /// 클리어 버튼 표시 여부 + var shouldShowClearButton: Bool { + trailing.hasClear && !text.isEmpty + } + + /// 최대 금액 도달 후 에러 상태에서는 추가 입력을 막는다. + var isCappedAtLimit: Bool { + guard + let maxAmount = variant.maxAmount, + state.isError + else { return false } + + return text == String(maxAmount) + } + + public init( + text: String = "", + variant: InputFieldVariant = .text(), + state: InputFieldState = .normal, + trailing: InputFieldTrailing = .none, + placeholder: String = "", + style: InputFieldStyle = .default + ) { + self.text = text + self.variant = variant + self.state = state + self.trailing = trailing + self.placeholder = placeholder + self.style = style + } + } + + public enum Action: Equatable, Sendable, BindableAction { + case binding(BindingAction) + case textChanged(String) + case clearButtonTapped + case validateInput + case triggerErrorHaptic + } + + public init() {} + + public var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .textChanged(let newText): + // (케이스별 차이 흡수) Variant에 따라 입력 처리 + switch state.variant { + case .amount(let maxAmount): + // 숫자만 허용 + let filtered = newText.filter { $0.isNumber } + + // 최대값 초과 시 자동으로 cap + 에러 상태 표시 + if let max = maxAmount, + let amount = Int(filtered), + amount > max { + let wasNotError = !state.state.isError + state.text = String(max) + state.state = .error(message: "*입력 한도") + + // 최초 한도 도달 시에만 햅틱 발생 (중복 방지) + if wasNotError { + return .send(.triggerErrorHaptic) + } + } else { + state.text = filtered + // 에러 상태 해제 + if state.state.isError { + state.state = .normal + } + } + return .none + + case .text(let maxLength): + // 최대 길이 제한 + if let max = maxLength { + state.text = String(newText.prefix(max)) + } else { + state.text = newText + } + return .none + } + + case .clearButtonTapped: + state.text = "" + // 에러 상태 해제 + if state.state.isError { + state.state = .normal + } + return .none + + case .validateInput: + // textChanged에서 모든 검증 처리 + return .none + + case .triggerErrorHaptic: + // 햅틱 피드백 발생 (부르르~ 진동) + return .run { _ in + await MainActor.run { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + } + } + } + } + } +} + +// MARK: - Helper Functions + +/// 천단위 콤마 포맷팅 +private func formatAmount(_ text: String) -> String { + guard let number = Int(text) else { return text } + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: number)) ?? text +} + +// MARK: - SwiftUI View + +public struct DoriInputFieldView: View { + @Bindable public var store: StoreOf + @State private var localFormattedText: String = "" + @FocusState private var isFocused: Bool + + public init(store: StoreOf) { + self.store = store + let initialText = store.text + self._localFormattedText = State( + initialValue: initialText.isEmpty ? "" : formatAmount(initialText) + ) + } + + public var body: some View { + // Main input container + HStack(spacing: store.style.spacing) { + // Text Field + textField + .pretendard(store.style.font) + .foregroundColor(textColor) + + Spacer(minLength: 0) + + // Trailing accessories + trailingView + } + .padding(.horizontal, store.style.horizontalPadding) + .frame(height: store.style.height) + .background(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: store.style.cornerRadius) + .stroke(borderColor, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: store.style.cornerRadius)) + } + + // MARK: - Subviews + + @ViewBuilder + private var textField: some View { + // (케이스별 차이 흡수) amount일 때는 포맷팅 적용 + if store.variant.isAmount { + TextField(store.placeholder, text: $localFormattedText) + .keyboardType(.numberPad) + .focused($isFocused) + .allowsHitTesting(!store.isCappedAtLimit) + .onChange(of: localFormattedText) { _, newValue in + handleAmountTextChanged(newValue) + } + .onChange(of: store.text) { _, newValue in + // 외부에서 text가 변경될 때 (+버튼, clear, cap 등) 무조건 동기화 + syncLocalFormattedText(with: newValue) + } + .onChange(of: store.state.state) { _, newState in + // 에러 발생 시 포커스 해제 → 키보드 dismiss + if newState.isError { + isFocused = false + } + } + } else { + TextField( + store.placeholder, + text: $store.text.sending(\.textChanged) + ) + .keyboardType(.default) + } + } + + @ViewBuilder + private var trailingView: some View { + HStack(spacing: store.style.spacing) { + // (케이스별 차이 흡수) 에러 시 unit 대신 에러 메시지 표시 + if let errorMessage = store.state.state.errorMessage { + Text(errorMessage) + .pretendard(store.style.errorMessageFont) + .foregroundColor(store.style.errorMessageColor) + } else if let unitText = store.trailing.unitText { + // Unit label (에러 없을 때만 표시) + Text(unitText) + .pretendard(store.style.unitFont) + .foregroundColor(store.style.unitColor) + } + + // Clear button (if should show) + if store.shouldShowClearButton { + Button { + store.send(.clearButtonTapped) + syncLocalFormattedText(with: store.text) + isFocused = false + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color(hex: "BDBDBD")) + .frame(width: 20, height: 20) + } + } + } + } + + // MARK: - Computed Properties + + private var textColor: Color { + store.state.state.isError + ? store.style.errorTextColor + : store.style.textColor + } + + private var backgroundColor: Color { + store.style.backgroundColor + } + + private var borderColor: Color { + store.style.normalBorderColor + } + + private func handleAmountTextChanged(_ newValue: String) { + let filtered = newValue.filter { $0.isNumber } + let maxAmountText = store.variant.maxAmount.map(String.init) + + if let maxAmount = store.variant.maxAmount, + let amount = Int(filtered), + amount > maxAmount { + localFormattedText = formatAmount(String(maxAmount)) + isFocused = false + store.send(.textChanged(filtered)) + return + } + + if store.isCappedAtLimit { + localFormattedText = formatAmount(store.text) + isFocused = false + return + } + + let formatted = filtered.isEmpty ? "" : formatAmount(filtered) + if localFormattedText != formatted { + localFormattedText = formatted + } + if store.text != filtered || (filtered == maxAmountText && store.state.state.isError) { + store.send(.textChanged(filtered)) + } + } + + private func syncLocalFormattedText(with rawText: String) { + let formatted = rawText.isEmpty ? "" : formatAmount(rawText) + if localFormattedText != formatted { + localFormattedText = formatted + } + } +} + +// MARK: - Preview + +#Preview("Input Field Cases") { + ScrollView { + VStack(spacing: 24) { + Group { + // Case 1: amount + unitAndClear + normal + VStack(alignment: .leading, spacing: 8) { + Text("금액 입력 (단위 + 클리어)") + DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "50000", + variant: .amount(maxAmount: 1000000), + state: .normal, + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력하세요" + ) + ) { + InputFieldFeature() + } + ) + } + + // Case 2: amount + unitOnly + normal + VStack(alignment: .leading, spacing: 8) { + Text("금액 입력 (단위만)") + DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "100000", + variant: .amount(), + state: .normal, + trailing: .unitOnly(text: "원"), + placeholder: "금액을 입력하세요" + ) + ) { + InputFieldFeature() + } + ) + } + + // Case 3: amount + unitAndClear + 최대값 (자동 cap) + VStack(alignment: .leading, spacing: 8) { + Text("금액 입력 (21억 한도)") + DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "2100000000", + variant: .amount(maxAmount: 2_100_000_000), + state: .normal, + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력하세요" + ) + ) { + InputFieldFeature() + } + ) + } + } + + Group { + // Case 4: text + clearOnly + normal + VStack(alignment: .leading, spacing: 8) { + Text("텍스트 입력 (일반)") + DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "검색어", + variant: .text(), + state: .normal, + trailing: .clearOnly, + placeholder: "검색어를 입력하세요" + ) + ) { + InputFieldFeature() + } + ) + } + + // Case 5: text + none + normal (empty) + VStack(alignment: .leading, spacing: 8) { + Text("텍스트 입력 (빈 상태)") + DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "", + variant: .text(), + state: .normal, + trailing: .clearOnly, + placeholder: "내용을 입력하세요" + ) + ) { + InputFieldFeature() + } + ) + } + } + } + .padding() + } +} + +// MARK: - Color Extension + +extension Color { + init(hex: String) { + let scanner = Scanner(string: hex) + var rgbValue: UInt64 = 0 + scanner.scanHexInt64(&rgbValue) + + let r = Double((rgbValue & 0xFF0000) >> 16) / 255.0 + let g = Double((rgbValue & 0x00FF00) >> 8) / 255.0 + let b = Double(rgbValue & 0x0000FF) / 255.0 + + self.init(red: r, green: g, blue: b) + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift b/Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift index 7aa8e89..bc56748 100644 --- a/Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift +++ b/Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift @@ -10,10 +10,11 @@ import SwiftUI public struct DoriTextField: View { @Binding var memo: String - + @State private var localText: String + private let placeholder: String private let maxLength: Int - + public init( _ placeholder: String, memo: Binding, @@ -22,26 +23,31 @@ public struct DoriTextField: View { self._memo = memo self.placeholder = placeholder self.maxLength = maxLength + self._localText = State(initialValue: memo.wrappedValue) } - + public var body: some View { ZStack(alignment: .center) { RoundedRectangle(cornerRadius: 10) .stroke(.grey300, lineWidth: 1) - + RoundedRectangle(cornerRadius: 10) .fill(.doriWhite) - - TextField( - placeholder, - text: Binding( - get: { memo }, - set: { memo = String($0.prefix(maxLength)) } - ) - ) - .pretendard(.body(.r3)) - .padding(.horizontal, 16) + TextField(placeholder, text: $localText) + .pretendard(.body(.r3)) + .padding(.horizontal, 16) + .onChange(of: localText) { _, newValue in + if newValue.count > maxLength { + localText = String(newValue.prefix(maxLength)) + } + memo = localText + } + .onChange(of: memo) { _, newValue in + if localText != newValue { + localText = newValue + } + } } .frame(height: 46) } diff --git a/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift index 4d80854..1e50747 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift @@ -12,19 +12,22 @@ public struct DoriSegmentGridWithMemo: View { let options: [DoriSegmentOption] @Binding var selection: ID @Binding var memo: String - + private let memoPlaceholder: String + private let columns: Int = 2 private var otherID: ID? { options.first(where: { $0.role == .other })?.id } private var isOtherSelected: Bool { selection == otherID } - + public init( options: [DoriSegmentOption], selection: Binding, - memo: Binding = .constant("") + memo: Binding = .constant(""), + memoPlaceholder: String = "관계를 입력하세요. (10자)" ) { self.options = options self._selection = selection self._memo = memo + self.memoPlaceholder = memoPlaceholder } public var body: some View { @@ -61,7 +64,7 @@ public struct DoriSegmentGridWithMemo: View { } private var memoField: some View { - DoriTextField("관계를 입력하세요. (10자)", memo: $memo) + DoriTextField(memoPlaceholder, memo: $memo) } // memo가 있으면 other를 강제 선택 diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift index a499ad7..14a75d2 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift @@ -8,7 +8,7 @@ import Foundation @MainActor -public enum TypoSemantic { +public enum TypoSemantic: Equatable { case headline(TypoSemantic.Heading) case title(TypoSemantic.Title) case subtitle(TypoSemantic.SubTitle) diff --git a/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift b/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift new file mode 100644 index 0000000..b8d0b56 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift @@ -0,0 +1,61 @@ +import ComposableArchitecture +import Testing +@testable import DoriDesignSystem + +@Suite("InputFieldFeature") +struct InputFieldFeatureTests { + + @Test("21억 초과 입력 시 21억으로 캡핑하고 에러 문구를 노출한다") + func amountTextChangedCapsAtLimit() async { + let store = TestStore( + initialState: InputFieldFeature.State( + variant: .amount(maxAmount: 2_100_000_000), + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력해주세요" + ) + ) { + InputFieldFeature() + } + + await store.send(.textChanged("9999999999")) { + $0.text = "2100000000" + $0.state = .error(message: "21억") + } + + await store.receive(.triggerErrorHaptic) + } + + @Test("상한값과 동일한 입력은 정상값으로 유지한다") + func amountTextChangedAllowsExactLimit() async { + let store = TestStore( + initialState: InputFieldFeature.State( + variant: .amount(maxAmount: 2_100_000_000) + ) + ) { + InputFieldFeature() + } + + await store.send(.textChanged("2100000000")) { + $0.text = "2100000000" + } + } + + @Test("clear 버튼 탭 시 텍스트와 에러 상태를 함께 초기화한다") + func clearButtonTappedResetsTextAndError() async { + let store = TestStore( + initialState: InputFieldFeature.State( + text: "2100000000", + variant: .amount(maxAmount: 2_100_000_000), + state: .error(message: "21억"), + trailing: .unitAndClear(unitText: "원") + ) + ) { + InputFieldFeature() + } + + await store.send(.clearButtonTapped) { + $0.text = "" + $0.state = .normal + } + } +} diff --git a/Projects/Core/Project.swift b/Projects/Core/Project.swift index f566ee1..ff2bbf3 100644 --- a/Projects/Core/Project.swift +++ b/Projects/Core/Project.swift @@ -11,7 +11,12 @@ import ProjectDescriptionHelpers let project = Project.dori( name: DoriLayer.core.projectName, targets: [ - .doriFramework(DoriModules.core.module), + .doriFramework( + DoriModules.core.module, + dependencies: [ + .external(.composableArchitecture), + ] + ), .doriFramework( DoriModules.designSystem.module, dependencies: [ @@ -19,6 +24,12 @@ let project = Project.dori( ], hasResources: true ), + .doriUnitTests( + DoriModules.designSystem.module, + dependencies: [ + .external(.composableArchitecture), + ] + ), ], resourceSynthesizers: [ .custom( diff --git a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift index 7409880..59ca074 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import DoriCore import DoriNetwork +import DoriDesignSystem @Reducer public struct AddDoriFeature { @@ -34,7 +35,11 @@ public struct AddDoriFeature { public var customEventType: String = "" // Page 3: 금액/날짜 - public var amountText: String = "" + public var amountInput = InputFieldFeature.State( + variant: .amount(maxAmount: 2_100_000_000), + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력해주세요" + ) public var eventDate: Date = .init() public var isDatePickerVisible: Bool = false public var isVisited: Visited = .yes @@ -57,9 +62,8 @@ public struct AddDoriFeature { } public var isPage3Valid: Bool { - let digits = amountText.filter(\.isNumber) - guard let amount = Int(digits) else { return false } - return amount > 0 + guard let amount = Int(amountInput.text) else { return false } + return amount > 0 && !amountInput.state.isError } // MARK: - Computed @@ -108,7 +112,7 @@ public struct AddDoriFeature { case customEventTypeChanged(String) // Page 3 - case amountTextChanged(String) + case amountInput(InputFieldFeature.Action) case addAmountTapped(Int) case datePickerToggled case eventDateChanged(Date) @@ -150,6 +154,10 @@ public struct AddDoriFeature { // MARK: - Reducer public var body: some ReducerOf { + Scope(state: \.amountInput, action: \.amountInput) { + InputFieldFeature() + } + Reduce { state, action in switch action { // MARK: Navigation @@ -245,21 +253,18 @@ public struct AddDoriFeature { return .none // MARK: Page 3 - case let .amountTextChanged(text): - let digits = text.filter(\.isNumber) - if let amount = Int(digits), amount > 0 { - let capped = min(amount, Self.maxAmount) - state.amountText = capped.decimalFormatted - } else { - state.amountText = "" - } + case .amountInput: return .none case let .addAmountTapped(amount): - let current = Int(state.amountText.filter(\.isNumber)) ?? 0 - let total = min(current + amount, Self.maxAmount) - state.amountText = total.decimalFormatted - return .none + if state.amountInput.state.isError { + return .none + } + + let current = Int(state.amountInput.text) ?? 0 + let effectiveMax = state.amountInput.variant.maxAmount ?? Self.maxAmount + let total = min(current + amount, effectiveMax) + return .send(.amountInput(.textChanged(String(total)))) case .datePickerToggled: state.isDatePickerVisible.toggle() @@ -292,7 +297,7 @@ public struct AddDoriFeature { partnerName: state.partnerName, relationship: state.resolvedRelationship, eventType: state.resolvedEventType, - amount: Int32(state.amountText.filter(\.isNumber)) ?? 0, + amount: Int32(state.amountInput.text) ?? 0, eventDate: eventDateString, isVisited: state.isVisited.boolValue, memo: state.memo.isEmpty ? nil : state.memo diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index 69adce5..e96e61b 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -27,6 +27,7 @@ public struct AddDoriView: View { ) } .background(.doriWhite) + .doriKeyboardDismissable() .navigationTitle("내역 추가") .navigationBarTitleDisplayMode(.inline) } diff --git a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift index 7e9eac9..035e93c 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift @@ -42,7 +42,8 @@ struct Page2RelationEventView: View { DoriSegmentGridWithMemo( options: options3x2, selection: $store.selectedEventType.sending(\.eventTypeSelected), - memo: $store.customEventType.sending(\.customEventTypeChanged) + memo: $store.customEventType.sending(\.customEventTypeChanged), + memoPlaceholder: "경조사를 입력하세요. (10자)" ) } } diff --git a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift index 6d53c02..9f51854 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift @@ -65,15 +65,14 @@ struct Page3AmountDateView: View { VStack(alignment: .leading, spacing: 12) { Text("도리") .addDoriSectionTitleStyle() - - TextField( - "금액을 입력해주세요", - text: $store.amountText.sending(\.amountTextChanged) + + DoriInputFieldView( + store: store.scope( + state: \.amountInput, + action: \.amountInput + ) ) - .keyboardType(.numberPad) - .pretendard(.body(.sb3)) - .roundedStyle() - + HStack(spacing: 8) { ForEach( amountPresets, diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift index e22cfd9..f6747be 100644 --- a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import DoriCore import DoriNetwork +import DoriDesignSystem @Reducer public struct EditDoriFeature { @@ -24,7 +25,7 @@ public struct EditDoriFeature { public var selectedEventType: EventType public var customEventType: String - public var amountText: String + public var amountInput: InputFieldFeature.State public var eventDate: Date public var isDatePickerVisible: Bool = false public var isVisited: Visited @@ -42,10 +43,9 @@ public struct EditDoriFeature { } public var isFormValid: Bool { - let digits = amountText.filter(\.isNumber) - guard let amount = Int(digits), amount > 0 else { return false } + guard let amount = Int(amountInput.text), amount > 0 else { return false } let hasEventType = selectedEventType != .other || !customEventType.trimmingCharacters(in: .whitespaces).isEmpty - return hasEventType + return hasEventType && !amountInput.state.isError } // MARK: - Init @@ -64,7 +64,12 @@ public struct EditDoriFeature { self.customEventType = dori.eventType } - self.amountText = Int(dori.amount).decimalFormatted + self.amountInput = InputFieldFeature.State( + text: String(dori.amount), + variant: .amount(maxAmount: 2_100_000_000), + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력해주세요" + ) let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" @@ -82,7 +87,7 @@ public struct EditDoriFeature { case eventTypeSelected(EventType) case customEventTypeChanged(String) - case amountTextChanged(String) + case amountInput(InputFieldFeature.Action) case addAmountTapped(Int) case datePickerToggled @@ -115,6 +120,10 @@ public struct EditDoriFeature { // MARK: - Reducer public var body: some ReducerOf { + Scope(state: \.amountInput, action: \.amountInput) { + InputFieldFeature() + } + Reduce { state, action in switch action { case let .transactionTypeChanged(type): @@ -132,21 +141,17 @@ public struct EditDoriFeature { state.customEventType = String(text.prefix(10)) return .none - case let .amountTextChanged(text): - let digits = text.filter(\.isNumber) - if let amount = Int(digits), amount > 0 { - let capped = min(amount, Self.maxAmount) - state.amountText = capped.decimalFormatted - } else { - state.amountText = "" - } + case .amountInput: return .none case let .addAmountTapped(amount): - let current = Int(state.amountText.filter(\.isNumber)) ?? 0 + if state.amountInput.state.isError { + return .none + } + + let current = Int(state.amountInput.text) ?? 0 let total = min(current + amount, Self.maxAmount) - state.amountText = total.decimalFormatted - return .none + return .send(.amountInput(.textChanged(String(total)))) case .datePickerToggled: state.isDatePickerVisible.toggle() @@ -175,7 +180,7 @@ public struct EditDoriFeature { let request = DoriUpdateInput( direction: state.transactionType, eventType: state.resolvedEventType, - amount: Int32(state.amountText.filter(\.isNumber)) ?? 0, + amount: Int32(state.amountInput.text) ?? 0, eventDate: eventDateString, isVisited: state.isVisited.boolValue, memo: state.memo.isEmpty ? nil : state.memo diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift index 0e11d53..9858dd6 100644 --- a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit import ComposableArchitecture import DoriDesignSystem import DoriCore @@ -36,21 +37,12 @@ public struct EditDoriView: View { Text("도리") .addDoriSectionTitleStyle() - HStack { - TextField( - "금액을 입력해주세요", - text: $store.amountText.sending(\.amountTextChanged) + DoriInputFieldView( + store: store.scope( + state: \.amountInput, + action: \.amountInput ) - .keyboardType(.numberPad) - .pretendard(.body(.sb3)) - - Spacer() - - Text("원") - .pretendard(.semiBold(.sb15)) - } - .roundedStyle() - + ) } // 내역 구분 @@ -72,7 +64,8 @@ public struct EditDoriView: View { DoriSegmentGridWithMemo( options: options3x2, selection: $store.selectedEventType.sending(\.eventTypeSelected), - memo: $store.customEventType.sending(\.customEventTypeChanged) + memo: $store.customEventType.sending(\.customEventTypeChanged), + memoPlaceholder: "경조사를 입력하세요. (10자)" ) } @@ -119,12 +112,11 @@ public struct EditDoriView: View { Text("메모(선택)") .addDoriSectionTitleStyle() - DoriTextField( + EditDoriMemoField( "메모를 입력해주세요 (40자)", - memo: $store.memo.sending(\.memoChanged), + text: $store.memo.sending(\.memoChanged), maxLength: 40 ) - .lineLimit(3...5) } // 저장 버튼 @@ -136,6 +128,7 @@ public struct EditDoriView: View { .padding(.horizontal, 16) .padding(.bottom, 20) } + .doriKeyboardDismissable() .navigationTitle(store.dori.partnerName) .navigationBarTitleDisplayMode(.inline) .overlay { @@ -156,6 +149,130 @@ public struct EditDoriView: View { } } +// MARK: - Memo Field + +struct EditDoriMemoField: View { + @Binding private var text: String + + private let placeholder: String + private let maxLength: Int + + static let minHeight: CGFloat = 46 + + init( + _ placeholder: String, + text: Binding, + maxLength: Int + ) { + self._text = text + self.placeholder = placeholder + self.maxLength = maxLength + } + + var body: some View { + ZStack(alignment: .topLeading) { + EditDoriMemoTextView( + text: $text, + maxLength: maxLength + ) + .frame(minHeight: Self.minHeight) + + if text.isEmpty { + Text(placeholder) + .pretendard(.body(.r3)) + .foregroundStyle(.grey400) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .allowsHitTesting(false) + } + } + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.doriWhite) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(.grey300, lineWidth: 1) + ) + } +} + +struct EditDoriMemoTextView: UIViewRepresentable { + @Binding var text: String + + let maxLength: Int + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UITextView { + FontManager.registerFontIfNeeded("Pretendard-Regular") + + let textView = UITextView() + textView.delegate = context.coordinator + textView.backgroundColor = .clear + textView.isScrollEnabled = false + textView.font = UIFont(name: "Pretendard-Regular", size: 15) ?? .systemFont(ofSize: 15) + textView.textColor = UIColor.label + textView.textContainer.lineFragmentPadding = 0 + textView.textContainerInset = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.text = String(text.prefix(maxLength)) + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + context.coordinator.parent = self + + let truncated = String(text.prefix(maxLength)) + if uiView.text != truncated { + uiView.text = truncated + } + + if truncated != text { + DispatchQueue.main.async { + text = truncated + } + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: UITextView, + context: Context + ) -> CGSize? { + let width = proposal.width ?? UIScreen.main.bounds.width + let fittingSize = uiView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + + return CGSize( + width: width, + height: max(EditDoriMemoField.minHeight, fittingSize.height) + ) + } + + final class Coordinator: NSObject, UITextViewDelegate { + var parent: EditDoriMemoTextView + + init(parent: EditDoriMemoTextView) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + let truncated = String(textView.text.prefix(parent.maxLength)) + if textView.text != truncated { + textView.text = truncated + } + + if parent.text != truncated { + parent.text = truncated + } + } + } +} + // MARK: - Calendar Picker private struct EditDoriCalendarView: View { diff --git a/Projects/Feature/History/Tests/EditDoriMemoFieldTests.swift b/Projects/Feature/History/Tests/EditDoriMemoFieldTests.swift new file mode 100644 index 0000000..b57d48f --- /dev/null +++ b/Projects/Feature/History/Tests/EditDoriMemoFieldTests.swift @@ -0,0 +1,146 @@ +import Combine +import ComposableArchitecture +import DoriCore +import SwiftUI +import Testing +import UIKit +@testable import FeatureHistory + +@MainActor +@Suite("EditDori 메모 입력") +struct EditDoriMemoFieldTests { + + @Test("memoChanged 액션은 40자로 제한된다") + func memoChangedCapsAt40Characters() async { + let store = TestStore( + initialState: EditDoriFeature.State(dori: .fixture()) + ) { + EditDoriFeature() + } + + let longText = String(repeating: "a", count: 45) + + await store.send(.memoChanged(longText)) { + $0.memo = String(longText.prefix(40)) + } + } + + @MainActor + @Test("긴 한 줄 메모는 너비를 넘기면 자동 줄바꿈되며 높이가 증가한다") + func memoFieldWrapsAndExpandsHeight() async throws { + let host = try makeHost() + let initialHeight = host.textView.frame.height + + host.state.text = String(repeating: "가", count: 40) + await settleLayout(controller: host.controller) + + #expect(host.textView.frame.height > initialHeight) + } + + @MainActor + @Test("41자 이상 입력하면 컴포넌트에서도 40자로 즉시 잘린다") + func memoFieldTruncatesPast40Characters() async throws { + let host = try makeHost() + + host.textView.text = String(repeating: "b", count: 45) + host.textView.delegate?.textViewDidChange?(host.textView) + await settleLayout(controller: host.controller) + + #expect(host.state.text.count == 40) + #expect(host.textView.text.count == 40) + } +} + +@MainActor +private func makeHost() throws -> ( + window: UIWindow, + controller: UIHostingController, + state: MemoFieldState, + textView: UITextView +) { + let state = MemoFieldState() + let controller = UIHostingController( + rootView: MemoFieldHarness(state: state) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 200, height: 400)) + window.rootViewController = controller + window.makeKeyAndVisible() + + controller.view.frame = window.bounds + controller.view.layoutIfNeeded() + + guard let textView = controller.view.firstSubview(of: UITextView.self) else { + throw MemoFieldHostError.textViewNotFound + } + + return (window, controller, state, textView) +} + +@MainActor +private func settleLayout(controller: UIViewController) async { + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(50)) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() +} + +@MainActor +private final class MemoFieldState: ObservableObject { + @Published var text = "" +} + +private struct MemoFieldHarness: View { + @ObservedObject var state: MemoFieldState + + var body: some View { + EditDoriMemoField( + "메모를 입력해주세요 (40자)", + text: Binding( + get: { state.text }, + set: { state.text = $0 } + ), + maxLength: 40 + ) + .frame(width: 180) + } +} + +private enum MemoFieldHostError: Error { + case textViewNotFound +} + +private extension UIView { + func firstSubview(of type: T.Type) -> T? { + if let view = self as? T { + return view + } + + for subview in subviews { + if let view = subview.firstSubview(of: type) { + return view + } + } + + return nil + } +} + +private extension Dori { + static func fixture(memo: String = "") -> Dori { + Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "결혼식", + amount: 50_000, + eventDate: "2026-03-14", + isVisited: true, + memo: memo, + createdAt: "2026-03-14" + ) + } +} diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index 3e9bdb9..319fb14 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -50,6 +50,12 @@ let project = Project.dori( .external(.composableArchitecture) ] ), + .doriUnitTests( + DoriModules.history.module, + dependencies: [ + .external(.composableArchitecture) + ] + ), .doriFramework( DoriModules.myPage.module, dependencies: [ diff --git a/textfield_testing_checklist.md b/textfield_testing_checklist.md new file mode 100644 index 0000000..ab13aad --- /dev/null +++ b/textfield_testing_checklist.md @@ -0,0 +1,205 @@ +# DoriTextField UI 테스트 체크리스트 + +> 생성일: 2026-03-14 +> 검증일: 2026-03-14 +> 대상 컴포넌트: `DoriTextField` (`Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift`) + +## 검증 결과 요약 + +| 항목 | 결과 | +|------|------| +| 기본 렌더링 (placeholder, 레이아웃) | ✅ 전 화면 통과 | +| 글자수 제한 (10자, 40자, 21억) | ✅ 통과 (한글/이모지는 MCP 미지원으로 미검증) | +| 양방향 바인딩 | ✅ 통과 | +| 키보드 인터랙션 | ✅ 통과 | +| DoriSegmentGridWithMemo 연동 | ✅ 기본 동작 통과 | +| 통합 플로우 (저장/등록) | ✅ 통과 | + +### 발견된 버그 및 수정 사항 + +| # | 버그 | 수정 파일 | 내용 | +|---|------|-----------|------| +| 1 | 경조사 "기타" placeholder 하드코딩 | `DoriSegmentGridWithMemo.swift` | `memoPlaceholder` 파라미터 추가, default = "관계를 입력하세요. (10자)" | +| 2 | Page2 경조사 섹션에 관계 placeholder 표시 | `Page2RelationEventView.swift` | `memoPlaceholder: "경조사를 입력하세요. (10자)"` 전달 | +| 3 | EditDori 경조사 섹션 동일 버그 | `EditDoriView.swift` | `memoPlaceholder: "경조사를 입력하세요. (10자)"` 전달 | + +--- + +## 사용 화면 정리 + +| # | 화면 | 파일 경로 | maxLength | 용도 | +|---|------|-----------|-----------|------| +| 1 | AddDori - Page1 | `Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift` | 10 | 도리 등록 시 이름 입력 | +| 2 | AddDori - Page2 | `Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift` | 10 | 도리 등록 시 관계 입력 | +| 2 | DoriSegmentGridWithMemo | `Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift` | 10 (기본값) | 커스텀 관계 직접 입력 | +| 3 | AddDori - Page3 | `Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift` | 21억 | 도리 등록 시 금액 입력 | +| 4 | AddDori - Page3 | `Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift` | 40 | 도리 등록 시 메모 입력 | +| 5 | EditDori | `Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift` | 40 | 도리 수정 시 메모 입력 | + +--- + +## 각 화면까지의 네비게이션 플로우 + +**화면 1: AddDori Page1 (이름 입력)** +``` +앱 실행 → MainTab → 캘린더 탭 → FAB 버튼 탭 +→ Page1 (이름 입력) +``` + +**화면 2: DoriSegmentGridWithMemo (커스텀 관계 직접 입력)** +``` +앱 실행 → MainTab → 캘린더 탭 → FAB 버튼 탭 +→ Page2 (관계 선택) → "기타" 항목 탭 → DoriTextField 노출 +``` + +**화면 3: Page3 (도리 입력)** +``` +앱 실행 → MainTab → 캘린더 탭 → FAB 버튼 탭 +→ Page1 (이름 입력) → Page2 (관계 선택) → Page3 (금액) +``` + +**화면 4: Page3 (메모 입력)** +``` +앱 실행 → MainTab → 캘린더 탭 → FAB 버튼 탭 +→ Page1 (이름 입력) → Page2 (관계 선택) → Page3 (메모) +``` + +**화면 5: EditDori (메모 수정)** +``` +앱 실행 → MainTab → 내역 탭 → 인물 카드 탭 +→ 거래 내역 행 선택 → 편집 버튼 탭 → EditDoriView +화면 1,2,3,4에서 사용되는 컴포넌트가 재활용되는데, 재활용이 잘되는지 확인하면됨 ! +``` + + +--- + +## 공통 검증 목록 + +### A. 기본 렌더링 +- [x] 텍스트필드가 화면에 표시되는지 확인 (높이 46pt, cornerRadius 10) +- [x] 플레이스홀더 텍스트가 올바르게 표시되는지 확인 + - [x] 화면 1: + - Page1: 상대방 이름을 입력하세요. (10자) ✅ + - [x] 화면 2: + - Page2(기타 탭시): 관계섹션: 관계를 입력하세요. (10자) ✅ + - Page2(기타 탭시): 경조사섹션: 경조사를 입력하세요. (10자) ✅ (버그 수정 적용됨) + - [x] 화면 3: + - Page3(도리 금액): 금액을 입력해주세요 ✅ + - Page3(금액 입력시): 금액(leading) 원(trailing) X(clear 버튼, trailing) ✅ + - [x] 화면 4: + - Page3(메모): 메모섹션: 메모를 입력해주세요 (40자) ✅ + - [x] 화면 5: - optional 값이 아니면, placeholder가 아닌 값이 들어가있음, 맥락 주의 + - (도리 금액): 50,000 (기존값 로드) ✅ + - (기타 탭시): 경조사섹션: 경조사를 입력하세요. (10자) ✅ + - (메모): 메모섹션: 메모를 입력해주세요 (40자) ✅ +- [x] 입력시 우측에 clear 버튼 생기는지 확인 + - [x] 화면 1: 이름 입력 ✅ (이전 세션 확인) + - [x] 화면 3: 도리 금액 ✅ (금액 입력 후 X 버튼 표시 확인) + + +### B. 글자수 제한 (maxLength) +- [x] **화면 1**: 10자까지 입력 가능한지 확인 ✅ (11자 입력 → "abcdefghij" 10자로 truncate) +- [x] **화면 2**: 10자까지 입력 가능한지 확인 ✅ (관계/경조사 모두 11자 입력 → 10자로 truncate) +- [x] **화면 3**: 21억 표시: 2,100,000,000 → 초과시 경고 UI 노출 ✅ +- [x] **화면 4**: 40자(메모섹션) 검증 ✅ (41자 입력 → 40자로 truncate) +- [x] **화면 5**: 40자(메모섹션) 검증 ✅ (메모 입력 및 저장 확인) +- [x] maxLength 초과 입력 시 잘림 처리 확인 + - 예: 41자 입력 시 → 40자로 truncate (`String.prefix(maxLength)`) ✅ + - [x] 화면 3: 9,999,999,999 입력 시 → 9,999,999,999 (limit 초과) 표시 ✅ + - [x] 화면 3: 초과 입력 시 → 텍스트 "원" -> "*입력 한도"로 바뀌는지 확인 ✅ + - [x] 화면 3: 초과 입력 시 → 금액과 *입력 한도 textcolor가 빨간색으로 바뀌는지 확인 ✅ +- [ ] 한글 입력 시 글자수 제한 정상 동작 확인 (MCP type_text 한글 미지원으로 미검증) +- [x] 영문 입력 시 글자수 제한 정상 동작 확인 ✅ +- [ ] 이모지 포함 혼합 입력 시 글자수 제한 정상 동작 확인 (MCP type_text 이모지 미지원으로 미검증) + +### C. 입력값 바인딩 (localText ↔ memo 양방향) +- [x] 텍스트 입력 시 외부 바인딩(`memo`)에 값이 전달되는지 확인 ✅ + - `onChange(of: localText)` → `memo = localText` 동작 검증 (저장 후 내역에 반영 확인) +- [x] 외부에서 `memo` 값 변경 시 텍스트필드에 반영되는지 확인 + - **화면 5 (EditDori)**: 기존 50,000원, 돌잔치, 예 등 초기값 로드 ✅ + - `onChange(of: memo)` → `localText = newValue` 동작 검증 ✅ + +### D. 키보드 인터랙션 +- [x] 텍스트필드 탭 시 키보드가 올라오는지 확인 ✅ (화면 2, 3, 4, 5 모두 확인) +- [x] 키보드 외 영역 탭 시 키보드 dismiss 확인 ✅ (return 키 dismiss 확인) + - **화면 5 (EditDori)**: `.doriKeyboardDismissable()` modifier 연동 검증 ✅ + - **화면 3 (AddDori Page3)**: 키보드 dismiss 동작 확인 ✅ + +### E. DoriSegmentGridWithMemo 연동 (화면 2, 5) +- [x] "기타" 항목 탭 → DoriTextField가 하단에 노출되는지 확인 ✅ (화면 2, 5 모두 확인) +- [x] DoriTextField에 텍스트 입력 → `isOtherSelected` = true 유지 확인 ✅ +- [ ] 다른 세그먼트 항목 선택 시 → `memo` 값이 초기화되는지 확인 (미검증) + - `syncMemoWithSelection` 동작 검증 +- [ ] 빈 텍스트 입력 → `syncSelectionWithMemo` 에서 early return (selection 변경 없음) 확인 (미검증) + +### F. 화면별 통합 플로우 검증 +- [x] **AddDori Page3**: 메모 입력 후 "완료" 버튼 활성화 확인 ✅ (금액+메모 입력 시 활성화) +- [x] **EditDori**: 기존값 로드 → 수정(경조사 "birthday", 메모 "test memo edit") → "저장" → 내역 반영 ✅ +- [x] **SegmentGrid**: "기타" 탭 → 관계/경조사명 입력 → "다음" 버튼으로 Page3 진행 ✅ + +--- + +## 검증 방법 (XcodeBuildMCP) + +``` +1. session_show_defaults → 프로젝트/스킴/시뮬레이터 확인 +2. build_run_sim → 앱 빌드 및 시뮬레이터 실행 +3. snapshot_ui → 현재 화면 UI 계층 구조 및 좌표 파악 +4. UI 자동화 → tap / type_text 로 각 검증 항목 수행 +5. screenshot → 각 단계 결과 캡처 +``` + +### 검증 순서 제안 + +``` +[Step 1] 앱 빌드 및 실행 + → build_run_sim + +[Step 2] 화면 1 (AddDori Page1 이름 입력) 검증 + → 캘린더 탭 → FAB → Page1 → 이름 + → snapshot_ui로 DoriTextField 좌표 확인 + → type_text로 10자 초과 입력 → 잘림 확인 + → screenshot + +[Step 2] 화면 2 (AddDori Page2 기타 탭 후 나온 텍스트필드) 검증 + → Page1 → Page2 → "기타" 탭 + → snapshot_ui로 DoriTextField 좌표 확인 + → type_text로 10자 초과 입력 → 잘림 확인 + → screenshot + +[Step 2] 화면 3 (AddDori Page3 금액) 검증 + → Page2 → Page3 도리 섹션 + → snapshot_ui로 DoriTextField 좌표 확인 + → type_text로 10자 초과 입력 → 잘림 확인 + → screenshot + +[Step 3] 화면 4 (AddDori Page3 메모) 검증 + → Page2 → Page3 메모 섹션 + → DoriTextField 탭 → 키보드 올라오는지 확인 + → 40자 초과 입력 → 잘림 확인 + → 키보드 외 영역 탭 → dismiss 확인 + → screenshot + +[Step 4] 화면 5 (EditDori 메모) 검증 + → 내역 탭 → 인물 카드 → 거래 내역 → 편집 + → 기존 메모값 표시 확인 + → 수정 입력 → 저장 + → screenshot +``` + +--- + +## 주요 코드 참조 + +| 항목 | 위치 | +|------|------| +| `DoriTextField` 구현 | `DoriTextField.swift:11-54` | +| `maxLength` 잘림 로직 | `DoriTextField.swift:41-43` | +| 양방향 바인딩 | `DoriTextField.swift:40-50` | +| AddDori 메모 섹션 | `Page3AmountDateView.swift:141-153` | +| EditDori 메모 섹션 | `EditDoriView.swift:117-128` | +| SegmentGrid 메모필드 | `DoriSegmentGridWithMemo.swift:63-65` | +| `syncSelectionWithMemo` | `DoriSegmentGridWithMemo.swift:68-75` | +| `syncMemoWithSelection` | `DoriSegmentGridWithMemo.swift:78-84` | +| `DoriKeyboardDismissModifier` | `DoriKeyboardDismissModifier.swift` |