From e4c410436a51d3f1dc7a89bc61561ff9ff9ec9d3 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 20:46:24 +0900 Subject: [PATCH 1/3] Improve AddDori paging and memo input --- Projects/App/Sources/DoriApp.swift | 72 +++++++++- .../Sources/DoriExpandingTextView.swift | 128 ++++++++++++++++++ .../Feature/AddDori/Sources/AddDoriView.swift | 62 ++++++++- .../Sources/Views/Page1NameTypeView.swift | 10 -- .../Views/Page2RelationEventView.swift | 19 ++- .../Sources/Views/Page3AmountDateView.swift | 40 +++--- 6 files changed, 282 insertions(+), 49 deletions(-) create mode 100644 Projects/Core/DoriDesignSystem/Sources/DoriExpandingTextView.swift diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 855bb12..5faf3a5 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -21,8 +21,15 @@ import PlatformKeychain @main struct DoriApp: App { let store: StoreOf + #if DEBUG + private let debugLaunchRoute: DebugLaunchRoute? + #endif init() { + #if DEBUG + self.debugLaunchRoute = DebugLaunchRoute(environment: ProcessInfo.processInfo.environment) + #endif + let tokenStore = KeychainAuthTokenStore(service: DoriKeychainKey.serviceID) // Store 참조를 위한 Box pattern @@ -79,11 +86,66 @@ struct DoriApp: App { var body: some Scene { WindowGroup { - AppView(store: store) - .preferredColorScheme(.light) // 다크모드 비활성화 - .onOpenURL { url in - _ = KakaoSDKHandler.handleOpenURL(url) - } + rootView + .preferredColorScheme(.light) + } + } + + @ViewBuilder + private var rootView: some View { + #if DEBUG + if let debugLaunchRoute { + debugLaunchRoute.makeView() + } else { + appView + } + #else + appView + #endif + } + + private var appView: some View { + AppView(store: store) + .onOpenURL { url in + _ = KakaoSDKHandler.handleOpenURL(url) + } + } +} + +#if DEBUG +private struct DebugLaunchRoute { + private let route: String + private let memo: String + + init?(environment: [String: String]) { + guard let route = environment["DORI_DEBUG_ROUTE"] else { return nil } + self.route = route + self.memo = environment["DORI_DEBUG_MEMO"] ?? "" + } + + @MainActor + @ViewBuilder + func makeView() -> some View { + switch route { + case "addDoriPage3": + NavigationStack { + AddDoriView( + store: Store(initialState: configuredState) { + AddDoriFeature() + } + ) + } + default: + EmptyView() } } + + @MainActor + private var configuredState: AddDoriFeature.State { + var state = AddDoriFeature.State() + state.currentPage = 2 + state.memo = memo + return state + } } +#endif diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriExpandingTextView.swift b/Projects/Core/DoriDesignSystem/Sources/DoriExpandingTextView.swift new file mode 100644 index 0000000..d73bdde --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriExpandingTextView.swift @@ -0,0 +1,128 @@ +// +// DoriExpandingTextView.swift +// Dori-iOS +// +// Created by 강동영 on 3/18/26. +// + +import SwiftUI +import UIKit + +public struct DoriExpandingTextView: View { + @Binding var text: String + @State private var dynamicHeight: CGFloat + + private let placeholder: String + private let maxLength: Int + private let minHeight: CGFloat + + public init( + _ placeholder: String, + text: Binding, + maxLength: Int = 40, + minHeight: CGFloat = 46 + ) { + self._text = text + self.placeholder = placeholder + self.maxLength = maxLength + self.minHeight = minHeight + self._dynamicHeight = State(initialValue: minHeight) + } + + public var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 10) + .fill(.doriWhite) + + RoundedRectangle(cornerRadius: 10) + .stroke(.grey300, lineWidth: 1) + + ExpandingTextViewRepresentable( + text: $text, + dynamicHeight: $dynamicHeight, + maxLength: maxLength, + minHeight: minHeight + ) + .frame(height: dynamicHeight) + + if text.isEmpty { + Text(placeholder) + .pretendard(.body(.r3)) + .foregroundStyle(.grey400) + .padding(.horizontal, 16) + .padding(.vertical, 13) + .allowsHitTesting(false) + } + } + .frame(minHeight: dynamicHeight) + .accessibilityIdentifier("addDori.memoField") + } +} + +private struct ExpandingTextViewRepresentable: UIViewRepresentable { + @Binding var text: String + @Binding var dynamicHeight: CGFloat + + let maxLength: Int + let minHeight: CGFloat + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = .init(top: 12, left: 16, bottom: 12, right: 16) + textView.textContainer.lineFragmentPadding = 0 + textView.font = UIFont(name: "Pretendard-Regular", size: 15) ?? .systemFont(ofSize: 15) + textView.textColor = UIColor(DoriColors.doriBlack.color) + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + textView.accessibilityIdentifier = "addDori.memoTextView" + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if uiView.text != text { + uiView.text = String(text.prefix(maxLength)) + } + + recalculateHeight(for: uiView) + } + + private func recalculateHeight(for textView: UITextView) { + let targetSize = CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude) + let measuredHeight = max(textView.sizeThatFits(targetSize).height, minHeight) + + guard abs(dynamicHeight - measuredHeight) > 0.5 else { return } + + DispatchQueue.main.async { + dynamicHeight = measuredHeight + } + } + + final class Coordinator: NSObject, UITextViewDelegate { + private var parent: ExpandingTextViewRepresentable + + init(_ parent: ExpandingTextViewRepresentable) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + let currentText = textView.text ?? "" + + if textView.markedTextRange == nil, currentText.count > parent.maxLength { + let truncatedText = String(currentText.prefix(parent.maxLength)) + textView.text = truncatedText + parent.text = truncatedText + } else { + parent.text = currentText + } + + parent.recalculateHeight(for: textView) + } + } +} diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index 4069363..9a4f385 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -12,6 +12,7 @@ import DoriDesignSystem public struct AddDoriView: View { @Bindable var store: StoreOf @Environment(\.dismiss) private var dismiss + @State private var isKeyboardVisible = false public init(store: StoreOf) { self.store = store @@ -23,6 +24,7 @@ public struct AddDoriView: View { pageIndicator pageContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .animation( .easeInOut(duration: 0.3), value: store.currentPage @@ -42,6 +44,15 @@ public struct AddDoriView: View { } ) ) + .safeAreaInset(edge: .bottom, spacing: 0) { + bottomCTA + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + isKeyboardVisible = true + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + isKeyboardVisible = false + } .allowsHitTesting(!store.isDatePickerVisible) if store.isDatePickerVisible { @@ -63,6 +74,53 @@ public struct AddDoriView: View { } } } + + @ViewBuilder + private var bottomCTA: some View { + PrimaryButton(title: currentButtonTitle) { + currentButtonAction() + } + .isEnable(isCurrentButtonEnabled) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 20) + .background(.doriWhite) + } + + private var currentButtonTitle: String { + switch store.currentPage { + case 0, 1: + return "다음" + case 2: + return "완료" + default: + return "다음" + } + } + + private var isCurrentButtonEnabled: Bool { + switch store.currentPage { + case 0: + return store.isPage1Valid + case 1: + return store.isPage2Valid + case 2: + return store.isPage3Valid + default: + return false + } + } + + private func currentButtonAction() { + switch store.currentPage { + case 0, 1: + store.send(.nextPageTapped) + case 2: + store.send(.submitTapped) + default: + break + } + } private var pageIndicator: some View { HStack { @@ -90,13 +148,13 @@ public struct AddDoriView: View { removal: .move(edge: .leading) )) case 1: - Page2RelationEventView(store: store) + Page2RelationEventView(store: store, isScrollEnabled: isKeyboardVisible) .transition(.asymmetric( insertion: .move(edge: .trailing), removal: .move(edge: .leading) )) case 2: - Page3AmountDateView(store: store) + Page3AmountDateView(store: store, isScrollEnabled: isKeyboardVisible) .transition(.asymmetric( insertion: .move(edge: .trailing), removal: .move(edge: .leading) diff --git a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift index f6beb8f..ecd557a 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift @@ -74,17 +74,8 @@ struct Page1NameTypeView: View { searchResultsList } } - - Spacer() - - // 다음 버튼 - PrimaryButton(title: "다음") { - store.send(.nextPageTapped) - } - .isEnable(store.isPage1Valid) } .padding(.horizontal, 16) - .padding(.bottom, 20) } private var searchResultsList: some View { @@ -192,4 +183,3 @@ struct Page1NameTypeView: View { ) } } - diff --git a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift index 035e93c..2c97ec1 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift @@ -12,6 +12,7 @@ import DoriCore struct Page2RelationEventView: View { @Bindable var store: StoreOf + let isScrollEnabled: Bool @State private var memo: String = "" @@ -20,7 +21,7 @@ struct Page2RelationEventView: View { private let options3x2: [DoriSegmentOption] = EventType.allCases.map { $0.toSegmentOptions() } var body: some View { - VStack(spacing: 0) { + ScrollView { VStack(alignment: .leading, spacing: 32) { // 관계 VStack(alignment: .leading, spacing: 12) { @@ -47,17 +48,12 @@ struct Page2RelationEventView: View { ) } } - - Spacer() - - // 다음 버튼 - PrimaryButton(title: "다음") { - store.send(.nextPageTapped) - } - .isEnable(store.isPage2Valid) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 16) - .padding(.bottom, 20) + .padding(.bottom, 16) + .scrollDisabled(!isScrollEnabled) + .scrollIndicators(.hidden) } } @@ -66,6 +62,7 @@ struct Page2RelationEventView: View { Page2RelationEventView( store: Store(initialState: AddDoriFeature.State()) { AddDoriFeature() - } + }, + isScrollEnabled: false ) } diff --git a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift index 585172a..5a73fe9 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift @@ -12,6 +12,7 @@ import DoriCore struct Page3AmountDateView: View { @Bindable var store: StoreOf + let isScrollEnabled: Bool private let amountPresets: [AmountPreset] = .presets private let options1x2: [DoriSegmentOption] = Visited.allCases.map { @@ -19,29 +20,26 @@ struct Page3AmountDateView: View { } var body: some View { - VStack(alignment: .leading, spacing: 24) { - // 금액 - amountSection + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // 금액 + amountSection - // 날짜 - dateSection + // 날짜 + dateSection - // 방문 여부 - visitedSection + // 방문 여부 + visitedSection - // 메모 - memoSection - - Spacer() - - // 다음 버튼 - PrimaryButton(title: "완료") { - store.send(.submitTapped) + // 메모 + memoSection } - .isEnable(store.isPage3Valid) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 16) - .padding(.bottom, 20) + .padding(.bottom, 16) + .scrollDisabled(!isScrollEnabled) + .scrollIndicators(.hidden) } // MARK: - Sections @@ -129,12 +127,11 @@ struct Page3AmountDateView: View { Text("메모(선택)") .addDoriSectionTitleStyle() - DoriTextField( + DoriExpandingTextView( "메모를 입력해주세요 (40자)", - memo: $store.memo.sending(\.memoChanged), + text: $store.memo.sending(\.memoChanged), maxLength: 40 ) - .lineLimit(3...5) } } } @@ -162,7 +159,8 @@ extension [AmountPreset] { Page3AmountDateView( store: Store(initialState: AddDoriFeature.State()) { AddDoriFeature() - } + }, + isScrollEnabled: false ) } From 0683d00bd27c23cae2c1144cb7c9f2172308148d Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 20:46:33 +0900 Subject: [PATCH 2/3] Fix calendar day selection and filter sheet styling --- .../Calendar/Sources/CalendarFeature.swift | 22 ++++++++++++++++--- .../Sources/Components/CalendarGridView.swift | 1 + .../Calendar/Sources/Domain/CalendarDay.swift | 4 ++++ .../PartnerDoriHistoryView.swift | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index 2564290..f22a64e 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -117,9 +117,15 @@ public struct CalendarFeature { return .none case let .dayTapped(day): - guard day.isCurrentMonth, let date = day.date else { return .none } + guard day.isSelectable, let date = day.date else { return .none } + let dayDoris = doris(for: date, state: state) + guard !dayDoris.isEmpty else { + state.selectedDay = nil + state.dayDoris = [] + return .none + } state.selectedDay = day - state.dayDoris = currentTypeDoris(state: state).filter { $0.eventDate.isSameDay(as: date) } + state.dayDoris = dayDoris return .none case .sheetDismissed: @@ -228,7 +234,13 @@ public struct CalendarFeature { private func updateSelectedDayDoris(state: inout State) { guard let selectedDay = state.selectedDay, let date = selectedDay.date else { return } - state.dayDoris = currentTypeDoris(state: state).filter { $0.eventDate.isSameDay(as: date) } + let dayDoris = doris(for: date, state: state) + guard !dayDoris.isEmpty else { + state.selectedDay = nil + state.dayDoris = [] + return + } + state.dayDoris = dayDoris } private func currentTypeDayList(state: State) -> [Int] { @@ -248,4 +260,8 @@ public struct CalendarFeature { return state.calendarData.inDoriList } } + + private func doris(for date: Date, state: State) -> [CalendarDori] { + currentTypeDoris(state: state).filter { $0.eventDate.isSameDay(as: date) } + } } diff --git a/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift b/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift index ec72ffc..bdf32fe 100644 --- a/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift +++ b/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift @@ -46,6 +46,7 @@ public struct CalendarGridView: View { day: calendarDay, selectedType: selectedType ) + .allowsHitTesting(calendarDay.isSelectable) .onTapGesture { onDayTapped(calendarDay) } diff --git a/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift b/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift index bc947f1..1172ab6 100644 --- a/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift +++ b/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift @@ -30,4 +30,8 @@ public struct CalendarDay: Identifiable, Equatable, Sendable { self.hasTransaction = hasTransaction self.isCurrentMonth = isCurrentMonth } + + public var isSelectable: Bool { + isCurrentMonth && hasTransaction + } } diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift index ce3b8a9..e6be823 100644 --- a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift @@ -111,6 +111,7 @@ public struct PartnerDoriHistoryView: View { store.send(.filterChanged(filter)) } ) + .presentationBackground(DoriDesignSystem.DoriColors.doriWhite.color) .presentationDetents([.height(200)]) } .overlay { @@ -175,7 +176,6 @@ private struct DoriFilterSheet: View { } .padding(.top, 12) - .background(.doriWhite) } } From 507c278997493eaa4af2edcea20f1911d358ae92 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 20:46:39 +0900 Subject: [PATCH 3/3] Document Common.xcconfig setup for new worktrees --- .claude/rules/git-worktree.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.claude/rules/git-worktree.md b/.claude/rules/git-worktree.md index 21eecce..0d7faac 100644 --- a/.claude/rules/git-worktree.md +++ b/.claude/rules/git-worktree.md @@ -30,6 +30,12 @@ git worktree add ../Dori-iOS-fix32 fix/32-textfield-validation git worktree add ../Dori-iOS-develop develop ``` +### 생성 직후 필수 체크 + +- 새 worktree를 만든 직후 `Projects/App/Resources/Common.xcconfig` 파일 존재 여부를 확인한다. +- 파일이 없으면 `~/Desktop/Dori-Workspace/Security_Common/Common.xcconfig`를 복사해서 동일 경로에 둔다. +- 이 파일이 없으면 `Tuist generate`, 앱 런치, 네트워크 설정이 모두 깨질 수 있다. + ### 네이밍 규칙 | 브랜치 | 워크트리 폴더명 |