From 556c808a61bbf9bbad2674e90bd2cb5ad6cdbd97 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 01:02:05 +0900 Subject: [PATCH 1/5] feat(app): improve add dori flow and session handling --- .claude/CLAUDE.md | 32 ++ .claude/rules/git-worktree.md | 81 +++++ .claude/rules/lessons-learned.md | 339 +++++++++++++++++- .gitignore | 1 + Projects/App/Sources/AppFeature.swift | 11 +- Projects/App/Sources/CustomTabBar.swift | 2 +- Projects/App/Sources/DoriApp.swift | 24 +- .../AuthInterceptorLogoutHandler.swift | 20 ++ .../DoriKeyboardDismissModifier.swift | 18 +- .../Sources/Components/InputField/README.md | 291 +++++++++++++++ .../Feature/AddDori/Sources/AddDoriView.swift | 55 ++- .../Sources/Views/Page1NameTypeView.swift | 20 +- .../Sources/Views/Page3AmountDateView.swift | 17 +- .../Calendar/Sources/CalendarFeature.swift | 37 +- .../Calendar/Sources/CalendarView.swift | 72 ++-- .../Sources/Components/CalendarGridView.swift | 13 +- .../PartnerDoriHistoryView.swift | 1 + .../MyPage/Sources/CommonWebView.swift | 4 +- .../Sources/AuthInterceptor.swift | 26 +- Projects/Infra/Project.swift | 1 + .../InfoPlist+Extension.swift | 1 + 21 files changed, 945 insertions(+), 121 deletions(-) create mode 100644 .claude/rules/git-worktree.md create mode 100644 Projects/Core/DoriCore/Sources/Dependencies/AuthInterceptorLogoutHandler.swift create mode 100644 Projects/Core/DoriDesignSystem/Sources/Components/InputField/README.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 854d644..48cc751 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -231,6 +231,9 @@ feature/* → develop → main ### 9.3 민감 설정 관리 See @rules/frontend/security.md +### 9.4 Git Worktree 워크플로우 +See @rules/git-worktree.md + --- ## 10. 테스트 전략 @@ -240,3 +243,32 @@ See @rules/frontend/test-strategy.md ### 10.3 MVP 제외 항목 - UI 테스트 / 스냅샷 테스트는 MVP에서 제외 + +--- + +## 11. 빌드 검증 규칙 + +### 11.1 작업 완료 후 필수 검증 + +작업이 완료되면 반드시 **xcodebuildmcp**로 빌드 검증을 수행한다. + +1. `session_show_defaults` — 프로젝트/스킴/시뮬레이터 확인 +2. `build_sim` — 빌드 검증 (DoriApp 스킴 기준) + +### 11.2 검증 실패 시 재시도 + +- 빌드 실패 시 원인을 분석하고 수정 후 **최대 5회까지** 재시도한다 +- 5회 초과 시 사용자에게 실패 원인을 보고하고 중단한다 + +### 11.4 검증 중 질문 금지 + +- 검증이 **모두 완료될 때까지** 사용자에게 질문하거나 진행 여부를 묻지 않는다 +- 검증 결과를 모아서 완료 후 한 번에 보고한다 + +### 11.3 검증 대상 스킴 + +| 스킴 | 용도 | +|------|------| +| `DoriApp` | 전체 앱 빌드 (기본 검증) | +| `DoriDesignSystem` | 디자인 시스템 단독 검증 | +| `FeatureCalendar` / `FeatureHistory` 등 | Feature 단독 검증 | diff --git a/.claude/rules/git-worktree.md b/.claude/rules/git-worktree.md new file mode 100644 index 0000000..21eecce --- /dev/null +++ b/.claude/rules/git-worktree.md @@ -0,0 +1,81 @@ +# Git Worktree 워크플로우 + +## 구조 원칙 + +워크트리는 반드시 **메인 레포와 형제(sibling) 폴더**로 생성한다. + +``` +Dori-Workspace/ ← git 미관리 폴더 (절대 git init 금지) + Dori-iOS/ ← main worktree (git repo 본체) + Dori-iOS-develop/ ← worktree: develop + Dori-iOS-feature31/ ← worktree: feature/31-... + Dori-iOS-fix32/ ← worktree: fix/32-... + Dori-iOS-fix34/ ← worktree: fix/34-... +``` + +**핵심 규칙**: `Dori-iOS` 폴더 **내부**에 워크트리를 생성하지 않는다. + +--- + +## 워크트리 추가 + +반드시 **`Dori-iOS` 디렉토리 안에서** 실행한다. + +```bash +cd ~/Desktop/Dori-Workspace/Dori-iOS + +# ../ 가 핵심 — 한 단계 위(Dori-Workspace)에 생성 +git worktree add ../Dori-iOS-feature31 feature/31-custom-navigationBar +git worktree add ../Dori-iOS-fix32 fix/32-textfield-validation +git worktree add ../Dori-iOS-develop develop +``` + +### 네이밍 규칙 + +| 브랜치 | 워크트리 폴더명 | +|--------|----------------| +| `develop` | `Dori-iOS-develop` | +| `feature/{n}-{desc}` | `Dori-iOS-feature{n}` | +| `fix/{n}-{desc}` | `Dori-iOS-fix{n}` | + +--- + +## 워크트리 제거 + +```bash +cd ~/Desktop/Dori-Workspace/Dori-iOS + +# 1. 워크트리 제거 (폴더도 함께 삭제됨) +git worktree remove ../Dori-iOS-feature31 + +# 2. 고아 참조 정리 +git worktree prune + +# 3. (선택) 브랜치도 삭제 +git branch -d feature/31-custom-navigationBar +``` + +### 강제 제거 (미커밋 변경사항이 있을 때) + +```bash +git worktree remove --force ../Dori-iOS-feature31 +``` + +--- + +## 현재 워크트리 확인 + +```bash +git worktree list +``` + +--- + +## 자주 하는 실수 + +| ❌ 잘못된 사용 | ✅ 올바른 사용 | +|---------------|---------------| +| `Dori-Workspace`에서 `git worktree add` 실행 | `Dori-iOS` 안에서 실행 | +| `git worktree add Dori-iOS-feature31 ...` (상대경로, `../` 없음) | `git worktree add ../Dori-iOS-feature31 ...` | +| `Dori-Workspace`에 `git init` | 절대 하지 않음 | +| 메인 레포 내부에 워크트리 생성 | 형제 폴더로 생성 | diff --git a/.claude/rules/lessons-learned.md b/.claude/rules/lessons-learned.md index a578627..4e7c63c 100644 --- a/.claude/rules/lessons-learned.md +++ b/.claude/rules/lessons-learned.md @@ -252,6 +252,150 @@ case .path(.popFrom(id: _)): --- +## 7. Custom Navigation Bar의 Swipe Back Gesture (2026-02-26) + +### 문제 상황 + +Custom Navigation Bar 구현 시 **swipe back 제스처가 작동하지 않는** 두 가지 문제 발생: + +1. **기본 문제**: `.navigationBarBackButtonHidden(true)` 사용 시 swipe back 비활성화 +2. **ScrollView 충돌**: 스크롤 후 swipe 시 ScrollView가 제스처를 먼저 소비 + +### ❌ 잘못된 접근: 제스처만 활성화 + +```swift +// 1단계: interactivePopGestureRecognizer만 활성화 +func updateUIViewController(...) { + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = context.coordinator +} + +class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + // ❌ 문제: gestureRecognizer.view는 UINavigationController가 아님! + if let navigationController = gestureRecognizer.view as? UINavigationController { + return navigationController.viewControllers.count > 1 + } + return false // 항상 false 반환 → 작동 안 함 + } +} +``` + +**문제점**: +- `gestureRecognizer.view`는 `UILayoutContainerView`이지 `UINavigationController`가 아님 +- 캐스팅 실패 → 항상 `false` 반환 +- **스크롤 후 swipe 시 ScrollView가 우선권**을 가져 pop이 작동 안 함 + +### ✅ 올바른 접근: Responder Chain + Gesture 우선순위 + +```swift +private struct NavigationControllerConfigurator: UIViewControllerRepresentable { + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + DispatchQueue.main.async { + if let navigationController = uiViewController.navigationController { + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = context.coordinator + } + } + } + + class Coordinator: NSObject, UIGestureRecognizerDelegate { + // 1. Responder Chain 탐색으로 UINavigationController 찾기 + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + var responder: UIResponder? = gestureRecognizer.view + while let current = responder { + if let navigationController = current as? UINavigationController { + return navigationController.viewControllers.count > 1 + } + responder = current.next + } + return true // SwiftUI가 자체 처리 + } + + // 2. ScrollView와 동시 인식 허용 + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return true + } + + // 3. 🔑 핵심: Pop gesture에 우선권 부여 + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // ScrollView의 pan gesture가 pop gesture에게 우선권 양보 + return otherGestureRecognizer is UIPanGestureRecognizer + } + } +} +``` + +### 해결 과정 + +**1단계: 기본 swipe 활성화** +- `interactivePopGestureRecognizer.isEnabled = true` +- Responder chain 탐색으로 `UINavigationController` 찾기 + +**2단계: ScrollView 충돌 해결** 🔑 +- `shouldRecognizeSimultaneouslyWith`: 두 제스처 동시 인식 허용 +- `shouldBeRequiredToFailBy`: **Pop gesture에 우선권 부여** + - ScrollView의 `UIPanGestureRecognizer`가 pop gesture에게 양보 + - 화면 가장자리 swipe → pop 우선 실행 + - 중앙 swipe → ScrollView 처리 + +### 작동 원리 + +``` +사용자가 화면 왼쪽 가장자리에서 swipe + ↓ +Pop gesture: "내가 먼저 확인!" (shouldBeRequiredToFailBy) + ↓ +Pop gesture: "화면 가장자리야? YES" + ↓ +Pop 실행! 🎉 (ScrollView는 양보) + ↓ +(가장자리 아니면 ScrollView가 처리) +``` + +### 적용 방법 + +```swift +public struct DoriNavigationBarModifier: ViewModifier { + public func body(content: Content) -> some View { + content + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + DoriNavigationBar(config: config) + } + .background( + NavigationControllerConfigurator() // ← 여기에 추가 + .frame(width: 0, height: 0) + ) + } +} +``` + +### 핵심 포인트 + +1. **Responder Chain 탐색**: `gestureRecognizer.view`는 container view이므로 chain을 따라 `UINavigationController` 찾기 +2. **Gesture 우선순위 설정**: `shouldBeRequiredToFailBy`로 pop gesture가 ScrollView보다 우선하도록 설정 +3. **동시 인식 허용**: `shouldRecognizeSimultaneouslyWith`로 두 제스처를 동시에 평가 +4. **자동 적용**: ViewModifier의 `.background()`에 configurator 추가하여 모든 뷰에 자동 적용 + +### 테스트 시나리오 + +- ✅ 화면 진입 직후 swipe → 정상 작동 +- ✅ 스크롤 후 즉시 swipe → 정상 작동 (ScrollView 양보) +- ✅ 스크롤 중 swipe → 정상 작동 (Pop 우선) +- ✅ 일반 스크롤 → 정상 작동 + +**참고**: iOS의 기본 NavigationBar도 동일한 방식으로 작동함 + +--- + ## 요약 | 항목 | ❌ 하지 말 것 | ✅ 해야 할 것 | @@ -261,8 +405,199 @@ case .path(.popFrom(id: _)): | **AuthInterceptor** | 메인 Session 사용 | 별도 Session 사용 | | **복잡도** | 과도한 최적화 | 단순하고 검증된 방법 | | **StackAction.popFrom** | isEmpty로 체크 | count == 1로 체크 | +| **Custom NavBar Swipe** | 제스처만 활성화 | Responder chain + 우선순위 설정 | +| **Amount TextField 포맷팅** | `.id()` 재생성 | `@State localFormattedText` 패턴 | +| **ZStack DatePicker 탭 차단** | overlay 레이어 분리만 시도 | `allowsHitTesting(false)` 로 하위 gesture 완전 차단 | + +--- + +## 8. Amount TextField 포맷팅 + 키보드 유지 (2026-03-13) + +### 문제 상황 + +금액 TextField에서 숫자 삭제 시 콤마 위치가 즉시 업데이트되지 않는 문제 발생. + +### ❌ 잘못된 접근: `.id()` modifier로 TextField 재생성 + +```swift +textField + .id("\(store.state.state)-\(store.variant.isAmount ? store.text : "")") +``` + +**문제점**: +- text 변경마다 TextField 재생성 → 키보드 dismiss +- 숫자를 입력/삭제할 때마다 소프트웨어 키보드가 닫힘 +- UX 심각하게 저하 + +### ✅ 올바른 접근: `@State private var localFormattedText` 패턴 + +```swift +public struct DoriInputFieldView: View { + @Bindable public var store: StoreOf + @State private var localFormattedText: String = "" + + public init(store: StoreOf) { + self.store = store + let initialText = store.text + self._localFormattedText = State( + initialValue: initialText.isEmpty ? "" : formatAmount(initialText) + ) + } +} + +// textField에서 +TextField(store.placeholder, text: $localFormattedText) + .keyboardType(.numberPad) + .onChange(of: localFormattedText) { _, newValue in + let filtered = newValue.filter { $0.isNumber } + let formatted = filtered.isEmpty ? "" : formatAmount(filtered) + if localFormattedText != formatted { + localFormattedText = formatted // 콤마 즉시 업데이트 + } + store.send(.textChanged(filtered)) + } + .onChange(of: store.text) { _, newValue in + // 외부 변경(+버튼, clear 등) 동기화 + let formatted = newValue.isEmpty ? "" : formatAmount(newValue) + if localFormattedText != formatted { + localFormattedText = formatted + } + } +``` + +**핵심**: +- `localFormattedText`를 직접 변경 → TextField 내부 버퍼와 동기화됨 +- TextField 재생성 없음 → 키보드 유지 +- `if localFormattedText != formatted` 조건으로 무한 루프 방지 +- 외부 변경(+버튼, clear)도 `.onChange(of: store.text)`로 동기화 + +**적용 범위**: `DoriTextField`의 `@State private var localText` 패턴과 동일한 원리 + +--- + +## 9. TextField 글자 수 제한 — Binding.set은 내부 버퍼를 건드리지 않는다 (2026-03-17) + +### 문제 상황 + +이름 TextField에 10자 제한을 `Binding.set`에서 구현했으나, 시뮬레이터에서 11자 이상 입력이 시각적으로 가능했음. + +### ❌ 잘못된 접근: `Binding.set`에서 prefix 적용 + +```swift +TextField( + "...", + text: Binding( + get: { store.searchQuery }, + set: { store.send(.searchQueryChanged(String($0.prefix(10)))) } + ) +) +``` + +**문제점**: +- `Binding.set`은 store 값만 10자로 저장 +- TextField의 **내부 버퍼**는 여전히 11자를 유지 +- SwiftUI re-render가 즉각 반영을 보장하지 않아 화면에 11자가 그대로 표시됨 + +### ✅ 올바른 접근: `@State localText` + `onChange` + +```swift +@State private var localNameText: String = "" + +TextField("...", text: $localNameText) + .onChange(of: localNameText) { _, newValue in + let truncated = String(newValue.prefix(10)) + if localNameText != truncated { + localNameText = truncated // TextField가 바라보는 변수 직접 수정 → 즉시 반영 + } + store.send(.searchQueryChanged(truncated)) + } + .onChange(of: store.searchQuery) { _, newValue in + if localNameText != newValue { + localNameText = newValue // 외부 변경(파트너 선택, clear 등) 동기화 + } + } +``` + +**핵심**: +- TextField가 직접 바인딩하는 `@State` 변수를 잘라내면 즉시 반영됨 +- `Binding.set`은 store만 업데이트할 뿐, TextField 내부 버퍼를 바꾸지 않음 +- `if localNameText != truncated` 조건으로 무한 루프 방지 + +**규칙**: **글자 수 제한이 필요한 모든 TextField는 반드시 `@State localText` + `onChange` 패턴을 사용한다.** + +--- + +--- + +## 10. ZStack Overlay에서 DatePicker Day Cell 선택 불가 (2026-03-18) + +### 문제 상황 + +`AddDoriCalendarView`를 ZStack overlay로 표시할 때 `< >` 버튼, "나가기", "날짜 선택" 버튼은 동작하지만 **DatePicker day cell (날짜 숫자)만 탭이 안 되는** 버그 발생. + +### 원인 + +`doriKeyboardDismissable()`이 적용된 VStack이 Calendar overlay 뒤에 있어도 `simultaneousGesture(TapGesture())`가 DatePicker의 내부 gesture를 방해함. + +```swift +// doriKeyboardDismissable() 내부 +content + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture().onEnded { /* 키보드 dismiss */ } + ) +``` + +- `simultaneousGesture`는 다른 gesture와 동시에 인식 시도 +- DatePicker day cell은 단순 Button이 아닌 내부 gesture 처리 로직 보유 +- ZStack 하위 레이어의 `simultaneousGesture`가 day cell gesture를 intercept + +### ❌ 잘못된 접근들 + +1. **overlay 분리** (Color.black + CalendarView 별도 overlay): 효과 없음 +2. **simultaneousGesture 유지한 채 레이어 조정**: 효과 없음 +3. **sheet 방식**: 팝업 형식이라 UX 요구사항 불충족 + +### ✅ 올바른 접근: allowsHitTesting으로 하위 레이어 완전 차단 + +```swift +ZStack { + VStack { /* Page 콘텐츠 */ } + .doriKeyboardDismissable() + .allowsHitTesting(!store.isDatePickerVisible) // ← 핵심 + + if store.isDatePickerVisible { + Color.black.opacity(0.4) + .ignoresSafeArea() + .allowsHitTesting(false) // dimming만, gesture 없음 + + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { store.send(.datePickerToggled) } // 외부 탭 → dismiss + + AddDoriCalendarView(...) // 최상위 레이어 → 단독으로 터치 수신 + } +} +``` + +**핵심**: +- `allowsHitTesting(false)`: 해당 뷰와 **모든 하위 뷰**의 터치를 완전 차단 (`.disabled()`와 달리 gesture까지 차단) +- Calendar 표시 중에는 VStack 전체가 hit test에서 제외 → `simultaneousGesture` 방해 없음 +- `Color.clear`의 `onTapGesture`가 Calendar 외부 탭을 처리 (exclusive, simultaneous 아님) +- `AddDoriCalendarView`가 최상위에서 터치를 온전히 수신 + +### allowsHitTesting vs disabled 차이 + +| | `allowsHitTesting(false)` | `.disabled(true)` | +|---|---|---| +| 터치 수신 | 차단 | 차단 | +| gesture 전파 | 차단 | **통과 (하위 뷰에 전달됨)** | +| 상위 레이어 영향 | 없음 | 없음 | + +→ Overlay 뒤의 gesture를 완전히 막으려면 반드시 `allowsHitTesting(false)` 사용 --- -**업데이트 일자**: 2026-02-25 -**관련 이슈**: 자동 로그인 기능 구현, History Stack 네비게이션 전환 +**업데이트 일자**: 2026-03-18 +**관련 이슈**: 자동 로그인 기능 구현, History Stack 네비게이션 전환, Custom Navigation Bar 구현, 금액 입력 버그 수정, TextField 글자 수 제한 버그 수정, DatePicker Overlay 날짜 선택 버그 수정 diff --git a/.gitignore b/.gitignore index 0d852dc..fa2d964 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ playground.xcworkspace Derived/ *.xcodeproj *.xcworkspace +.claude/worktrees diff --git a/Projects/App/Sources/AppFeature.swift b/Projects/App/Sources/AppFeature.swift index 60b0bf2..7ca8f03 100644 --- a/Projects/App/Sources/AppFeature.swift +++ b/Projects/App/Sources/AppFeature.swift @@ -29,6 +29,7 @@ struct AppFeature { case splash(SplashFeature.Action) case intro(IntroFeature.Action) case mainTab(MainTabFeature.Action) + case forceLogout // AuthInterceptor에서 발생하는 강제 로그아웃 } var body: some ReducerOf { @@ -50,15 +51,21 @@ struct AppFeature { case .splash(.delegate(.unauthenticated)): state.route = .intro return .none - + case .intro(.delegate(.loginSucceeded)): state.route = .mainTab return .none - + case .mainTab(.delegate(.needsAuthentication)): state.route = .intro return .none + case .forceLogout: + // Refresh token 만료로 인한 강제 로그아웃 + print("⚠️ Refresh token expired. Forcing logout.") + state.route = .intro + return .none + case .splash, .intro, .mainTab: return .none } diff --git a/Projects/App/Sources/CustomTabBar.swift b/Projects/App/Sources/CustomTabBar.swift index f2d9eb6..7a19142 100644 --- a/Projects/App/Sources/CustomTabBar.swift +++ b/Projects/App/Sources/CustomTabBar.swift @@ -34,7 +34,7 @@ struct CustomTabBar: View { TabBarItem( icon: selectedTab == .myPage ? Image(.tabbarMypageFill) : Image(.tabbarMypage), - title: "마이페이지", + title: "마이홈", isSelected: selectedTab == .myPage ) { selectedTab = .myPage diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index fe232cb..855bb12 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -24,10 +24,23 @@ struct DoriApp: App { init() { let tokenStore = KeychainAuthTokenStore(service: DoriKeychainKey.serviceID) - let interceptor = AuthInterceptor(tokenStore: tokenStore) + + // Store 참조를 위한 Box pattern + final class StoreBox: @unchecked Sendable { + var store: StoreOf? + } + let storeBox = StoreBox() + + let interceptor = AuthInterceptor(tokenStore: tokenStore) { @MainActor in + storeBox.store?.send(.forceLogout) + } + #if DEBUG + let config = URLSessionConfiguration.default + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil let networkService = NetworkServiceImpl( - configuration: .default, + configuration: config, logger: NetworkLogger(), interceptor: interceptor ) @@ -39,7 +52,7 @@ struct DoriApp: App { ) #endif - self.store = Store(initialState: AppFeature.State()) { + let store = Store(initialState: AppFeature.State()) { AppFeature() } withDependencies: { $0.authTokenStore = .live(tokenStore: tokenStore) @@ -47,7 +60,7 @@ struct DoriApp: App { networkService: networkService, tokenStore: tokenStore ) - + $0.addDoriAPIClient = .live(networkService: networkService) $0.calendarClient = .live(networkService: networkService) $0.historyAPIClient = .live(networkService: networkService) @@ -57,6 +70,9 @@ struct DoriApp: App { ) } + storeBox.store = store + self.store = store + FontManager.registerAllFonts() KakaoSDKHandler.initializeFromMainBundle() } diff --git a/Projects/Core/DoriCore/Sources/Dependencies/AuthInterceptorLogoutHandler.swift b/Projects/Core/DoriCore/Sources/Dependencies/AuthInterceptorLogoutHandler.swift new file mode 100644 index 0000000..d15d550 --- /dev/null +++ b/Projects/Core/DoriCore/Sources/Dependencies/AuthInterceptorLogoutHandler.swift @@ -0,0 +1,20 @@ +// +// AuthInterceptorLogoutHandler.swift +// Dori-iOS +// +// Created by 강동영 on 2/26/26. +// + +import Dependencies + +extension DependencyValues { + public var authInterceptorLogoutHandler: @Sendable () async -> Void { + get { self[AuthInterceptorLogoutHandlerKey.self] } + set { self[AuthInterceptorLogoutHandlerKey.self] = newValue } + } +} + +private enum AuthInterceptorLogoutHandlerKey: DependencyKey { + public static let liveValue: @Sendable () async -> Void = {} + public static let testValue: @Sendable () async -> Void = {} +} diff --git a/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift b/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift index 786cfe7..1cff8ab 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Components/DoriKeyboardDismissModifier.swift @@ -29,14 +29,16 @@ 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 - ) - } + .simultaneousGesture( + TapGesture().onEnded { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + ) } } diff --git a/Projects/Core/DoriDesignSystem/Sources/Components/InputField/README.md b/Projects/Core/DoriDesignSystem/Sources/Components/InputField/README.md new file mode 100644 index 0000000..c6dac37 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Components/InputField/README.md @@ -0,0 +1,291 @@ +# DoriInputField + +재사용 가능한 입력 필드 컴포넌트 (TCA 기반) + +## 설계 원칙 + +### 1. 케이스 기반 설계 + +**세 가지 독립적인 축으로 케이스를 분리:** + +#### (A) Variant 축 - 입력 필드의 용도/타입 +```swift +enum InputFieldVariant { + case amount(maxAmount: Int? = nil) // 금액 입력 + case text(maxLength: Int? = nil) // 텍스트 입력 +} +``` + +**케이스별 차이 흡수:** +- `amount`: 숫자만 허용, 천단위 콤마 자동 포맷팅, 최대 금액 검증 + cap +- `text`: 일반 텍스트, 최대 길이 제한 + +#### (B) State 축 - 입력 필드의 표현 상태 +```swift +enum InputFieldState { + case normal + case error(message: String) +} +``` + +**케이스별 차이 흡수:** +- `normal`: 기본 보더 색상, 기본 텍스트 색상 +- `error`: 빨간 텍스트 + trailing 영역에 에러 메시지 표시, 포커스 자동 해제 + +> **참고**: `focused` / `disabled` 상태는 별도 enum이 없습니다. +> - 포커스는 View 내부의 `@FocusState private var isFocused`로 관리합니다. +> - 비활성화가 필요한 경우 SwiftUI의 `.disabled()` modifier를 사용합니다. + +#### (C) Trailing 축 - 우측 액세서리 +```swift +enum InputFieldTrailing { + case none + case unitOnly(text: String) // 단위 라벨만 + case clearOnly // 클리어 버튼만 + case unitAndClear(unitText: String) // 둘 다 +} +``` + +**케이스별 차이 흡수:** +- `unitOnly`: "원" 등의 단위 표시 (에러 시 에러 메시지로 대체) +- `clearOnly`: X 버튼 (텍스트 비어있을 때 숨김) +- `unitAndClear`: 단위 라벨 + X 버튼 동시 표시 (에러 시 단위 대신 에러 메시지) + +--- + +## 구조 + +### 1. 스타일 분리 (InputFieldStyle) +색상, 폰트, 패딩, 높이 등 시각적 요소를 토큰화 + +```swift +public struct InputFieldStyle { + 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 +} +``` + +> **참고**: `focusedBorderColor`, `errorBorderColor`는 없습니다. 보더 색상은 항상 `normalBorderColor`를 사용하며, 에러 상태는 텍스트 색상 + trailing 에러 메시지로 표현합니다. + +### 2. TCA Reducer (InputFieldFeature) +상태 관리 및 비즈니스 로직 + +**Action:** +- `binding(BindingAction)`: TCA BindingReducer 연동 +- `textChanged(String)`: 입력 값 변경 (Variant에 따라 필터링) +- `clearButtonTapped`: 클리어 버튼 탭 +- `validateInput`: 입력 검증 (현재 textChanged에서 통합 처리) +- `triggerErrorHaptic`: 에러 햅틱 피드백 발생 + +**State:** +- `text`: 실제 값 (숫자만 저장) +- `variant`: 입력 타입 및 제한값 +- `state`: 현재 표현 상태 (normal / error) +- `trailing`: 우측 액세서리 구성 +- `placeholder`: 플레이스홀더 문자열 +- `style`: 시각 토큰 +- `displayText` (computed): 표시용 값 (amount일 때 천단위 콤마 포함) +- `shouldShowClearButton` (computed): 클리어 버튼 표시 여부 +- `isCappedAtLimit` (computed): 최대 금액 도달 + 에러 상태일 때 true → 추가 입력 차단 + +### 3. SwiftUI View (DoriInputFieldView) +TCA Store를 받아 UI 렌더링 + +**amount Variant 특이사항:** +- `@State private var localFormattedText` 패턴으로 천단위 콤마를 키보드 유지 상태에서 즉시 업데이트 +- 에러(최대값 초과) 발생 시 포커스 자동 해제 (키보드 dismiss) +- `isCappedAtLimit == true`이면 TextField 터치를 막아 추가 입력 차단 + +**text Variant:** +- `$store.text.sending(\.textChanged)` 직접 바인딩 사용 + +--- + +## 사용 예시 + +### 1. 금액 입력 (단위 + 클리어 버튼) +```swift +DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "50000", + variant: .amount(maxAmount: 1000000), + state: .normal, + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력하세요" + ) + ) { + InputFieldFeature() + } +) +``` + +**동작:** +- 숫자만 입력 가능 +- 자동 천단위 콤마 (50,000) +- 100만원 초과 시 자동으로 `.error(message: "*입력 한도")` 전환 + 에러 햅틱 +- 에러 시 trailing 영역에 "*입력 한도" 표시 (단위 텍스트 대체) +- 에러 시 포커스 자동 해제 (키보드 dismiss) +- 클리어 버튼으로 초기화 가능 (에러 상태도 함께 해제) + +### 2. 텍스트 입력 (클리어 버튼만) +```swift +DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "홍길동", + variant: .text(maxLength: 20), + state: .normal, + trailing: .clearOnly, + placeholder: "이름을 입력하세요" + ) + ) { + InputFieldFeature() + } +) +``` + +**동작:** +- 일반 텍스트 입력 +- 최대 20자 제한 (초과 입력 시 즉시 잘림) +- 클리어 버튼으로 초기화 가능 + +### 3. 에러 상태 +```swift +DoriInputFieldView( + store: Store( + initialState: InputFieldFeature.State( + text: "1000000", + variant: .amount(maxAmount: 1000000), + state: .error(message: "*입력 한도"), + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력하세요" + ) + ) { + InputFieldFeature() + } +) +``` + +**UI 변화:** +- 빨간 텍스트 +- trailing 영역에 "*입력 한도" 표시 (단위 텍스트 대신) +- 추가 입력 차단 (`isCappedAtLimit == true`) + +--- + +## 실전 사용 (Parent Feature에서) + +```swift +@Reducer +struct AddDoriFeature { + @ObservableState + struct State { + var amountInput = InputFieldFeature.State( + variant: .amount(maxAmount: 10_000_000), + trailing: .unitAndClear(unitText: "원"), + placeholder: "금액을 입력하세요" + ) + + var nameInput = InputFieldFeature.State( + variant: .text(maxLength: 10), + trailing: .clearOnly, + placeholder: "이름을 입력하세요" + ) + } + + enum Action { + case amountInput(InputFieldFeature.Action) + case nameInput(InputFieldFeature.Action) + case submitButtonTapped + } + + var body: some ReducerOf { + Scope(state: \.amountInput, action: \.amountInput) { + InputFieldFeature() + } + Scope(state: \.nameInput, action: \.nameInput) { + InputFieldFeature() + } + + Reduce { state, action in + switch action { + case .submitButtonTapped: + // state.amountInput.text 사용 + let amount = Int(state.amountInput.text) ?? 0 + let name = state.nameInput.text + return .none + + case .amountInput, .nameInput: + return .none + } + } + } +} +``` + +--- + +## 확장 포인트 + +### 1. 커스텀 스타일 적용 +```swift +var customStyle = InputFieldStyle.default +customStyle.normalBorderColor = .blue +customStyle.height = 60 + +InputFieldFeature.State( + variant: .text(), + style: customStyle +) +``` + +### 2. 커스텀 검증 로직 (Parent에서 에러 상태 직접 제어) +```swift +// Parent Reducer에서 +case .amountInput(.textChanged): + if let amount = Int(state.amountInput.text), + amount < 1000 { + state.amountInput.state = .error(message: "*최소 1,000원") + } + return .none +``` + +### 3. 포커스 제어 +```swift +// View에서 — DoriInputFieldView 내부의 @FocusState는 외부 접근 불가이므로 +// 포커스 제어가 필요한 경우 SwiftUI의 .focused() modifier를 View 레벨에서 조합 +DoriInputFieldView(store: store.scope(state: \.amountInput, action: \.amountInput)) +``` + +--- + +## 케이스별 차이 흡수 전략 요약 + +| 케이스 | 차이점 | 흡수 방법 | +|--------|--------|-----------| +| **Variant** | 입력 필터링, 포맷팅 | `textChanged` 액션에서 분기 처리 | +| **State** | 텍스트 색상, 에러 메시지 | Computed property로 색상 계산, trailing 영역 조건부 렌더링 | +| **Trailing** | 우측 요소 조합 | enum + `@ViewBuilder`로 조건부 렌더링 | + +**핵심**: 각 축을 독립적인 enum으로 분리 → Reducer에서 조합 로직 처리 → View는 단순 렌더링 diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index b5c696c..4069363 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -18,23 +18,50 @@ public struct AddDoriView: View { } public var body: some View { - VStack(spacing: 32) { - pageIndicator + ZStack { + VStack(spacing: 32) { + pageIndicator - pageContent - .animation( - .easeInOut(duration: 0.3), - value: store.currentPage + pageContent + .animation( + .easeInOut(duration: 0.3), + value: store.currentPage + ) + } + .background(.doriWhite) + .doriKeyboardDismissable() + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitle( + "내역 추가", + onBack: { + if store.currentPage == 0 { + dismiss() + } else { + store.send(.previousPageTapped) + } + } ) - } - .background(.doriWhite) - .doriKeyboardDismissable() - .doriNavigationBar( - DoriNavigationBarConfig.backWithTitle( - "내역 추가", - onBack: { dismiss() } ) - ) + .allowsHitTesting(!store.isDatePickerVisible) + + if store.isDatePickerVisible { + Color.black.opacity(0.4) + .ignoresSafeArea() + .allowsHitTesting(false) + + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { store.send(.datePickerToggled) } + + AddDoriCalendarView(initialDate: store.eventDate) { + store.send(.datePickerToggled) + } selecionAction: { date in + store.send(.eventDateChanged(date)) + store.send(.datePickerToggled) + } + } + } } private var pageIndicator: some View { diff --git a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift index dcde545..f6beb8f 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift @@ -13,6 +13,7 @@ import DoriNetwork struct Page1NameTypeView: View { @Bindable var store: StoreOf + @State private var localNameText: String = "" private let options2x2: [DoriSegmentOption] = [ .init(id: .judori, title: TransactionType.judori.displayName, role: .normal), @@ -40,15 +41,24 @@ struct Page1NameTypeView: View { HStack { TextField( "상대방 이름을 입력하세요. (10자)", - text: Binding( - get: { store.searchQuery }, - set: { store.send(.searchQueryChanged(String($0.prefix(10)))) } - ) + text: $localNameText ) .pretendard(.body(.sb3)) .foregroundStyle(.doriBlack) + .onChange(of: localNameText) { _, newValue in + let truncated = String(newValue.prefix(10)) + if localNameText != truncated { + localNameText = truncated + } + store.send(.searchQueryChanged(truncated)) + } + .onChange(of: store.searchQuery) { _, newValue in + if localNameText != newValue { + localNameText = newValue + } + } - if !store.searchQuery.isEmpty { + if !localNameText.isEmpty { Button { store.send(.clearSearchTapped) } label: { diff --git a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift index 9f51854..aa696db 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift @@ -42,21 +42,6 @@ struct Page3AmountDateView: View { } .padding(.horizontal, 16) .padding(.bottom, 20) - .overlay { - if store.isDatePickerVisible { - Color.black.opacity(0.4) - .ignoresSafeArea() - .onTapGesture { store.send(.datePickerToggled) } - .overlay { - AddDoriCalendarView(initialDate: store.eventDate) { - store.send(.datePickerToggled) - } selecionAction: { date in - store.send(.eventDateChanged(date)) - store.send(.datePickerToggled) - } - } - } - } } // MARK: - Sections @@ -204,7 +189,7 @@ struct AddDoriCalendarView: View { displayedComponents: .date ) .datePickerStyle(.graphical) - .tint(DoriColors.main.color) + .tint(DoriColors.secondary.color) .padding() .background(DoriColors.doriWhite.color) .clipShape(RoundedRectangle(cornerRadius: 16)) diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index a373c34..2564290 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -199,23 +199,28 @@ public struct CalendarFeature { ) } - // 다음달 날짜 채우기 (총 35칸 = 5줄) - let remaining = 35 - days.count - for i in 0.. 0 { + for i in 0.. 28 ? index - 29 : index - 1), @@ -139,4 +141,3 @@ struct CalendarDayCell: View { ) .padding() } - diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift index e1ff437..ce3b8a9 100644 --- a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift @@ -175,6 +175,7 @@ private struct DoriFilterSheet: View { } .padding(.top, 12) + .background(.doriWhite) } } diff --git a/Projects/Feature/MyPage/Sources/CommonWebView.swift b/Projects/Feature/MyPage/Sources/CommonWebView.swift index ef7e749..9e6eff3 100644 --- a/Projects/Feature/MyPage/Sources/CommonWebView.swift +++ b/Projects/Feature/MyPage/Sources/CommonWebView.swift @@ -182,13 +182,13 @@ struct CommonWebView: View { let navigationTitle: String let url: URL - @Environment(\.dismiss) private var dismiss @State private var isLoading = false @State private var progress: Double = 0 @State private var canGoBack = false @State private var canGoForward = false @State private var webViewStore = WebViewStore() - + @Environment(\.dismiss) private var dismiss + init( navigationTitle: String, url: URL diff --git a/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift b/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift index 4eba539..662e9be 100644 --- a/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift +++ b/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift @@ -15,10 +15,15 @@ public final class AuthInterceptor: RequestInterceptor { private let maxRetryCount = 1 private let coordinator = RefreshCoordinator() private let session: Session - - public init(tokenStore: any AuthTokenStoring) { + private let logoutHandler: @Sendable () async -> Void + + public init( + tokenStore: any AuthTokenStoring, + logoutHandler: @escaping @Sendable () async -> Void = {} + ) { self.tokenStore = tokenStore self.session = Session() + self.logoutHandler = logoutHandler } private actor RefreshCoordinator { @@ -48,9 +53,11 @@ public final class AuthInterceptor: RequestInterceptor { completion: @escaping (Result) -> Void ) { var request = urlRequest - let accessToken = tokenStore.load().accessToken - - if let accessToken, !accessToken.isEmpty { + let token = tokenStore.load() + + if let accessToken = token.accessToken, !accessToken.isEmpty { + print("🔑 accessToken: \(accessToken)") + print("🔑 refreshToken: \(token.refreshToken)") request.setValue( "Bearer \(accessToken)", forHTTPHeaderField: authorizationKey @@ -66,7 +73,7 @@ public final class AuthInterceptor: RequestInterceptor { dueTo error: any Error, completion: @escaping @Sendable (RetryResult) -> Void) { print(#function) - guard let response = request.task?.response as? HTTPURLResponse, + guard let response = request.response, response.statusCode == 401 else { completion(.doNotRetryWithError(error)) return @@ -100,7 +107,7 @@ public final class AuthInterceptor: RequestInterceptor { guard let refreshToken = tokens.refreshToken else { return false } - + print("🔑 refreshToken: \(refreshToken)") guard let request = try? RefreshEndpoint(refreshToken: refreshToken).createURLRequest() else { return false } @@ -128,6 +135,11 @@ public final class AuthInterceptor: RequestInterceptor { private func handleLogout() { try? tokenStore.clear() + + // TCA 방식으로 강제 로그아웃 전파 + Task { @MainActor in + await logoutHandler() + } } } diff --git a/Projects/Infra/Project.swift b/Projects/Infra/Project.swift index 05fcf93..6631086 100644 --- a/Projects/Infra/Project.swift +++ b/Projects/Infra/Project.swift @@ -18,6 +18,7 @@ let project = Project.dori( dependencies: [ DoriModules.network.module.targetDependency, .external(.alamofire), + .external(.composableArchitecture), ] ), ] diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 1df6cef..514035d 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -13,6 +13,7 @@ extension InfoPlist { "BASE_URL": "$(BASE_URL)", "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "Appearance": "Light", + "ITSAppUsesNonExemptEncryption": .boolean(false), "CFBundleURLTypes": [ [ "CFBundleTypeRole": "Editor", From e285eb26b1c0257219f1dcae435461fe9cecd038 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 15:28:27 +0900 Subject: [PATCH 2/5] fix(ui): adjust add dori controls and testability settings --- .../Tests/InputFieldFeatureTests.swift | 9 ++++++--- .../Sources/Views/Page3AmountDateView.swift | 3 ++- .../Sources/Components/DoriBarGraphView.swift | 19 ++++++++++--------- .../Settings+Extension.swift | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift b/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift index b8d0b56..3eb810e 100644 --- a/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift +++ b/Projects/Core/DoriDesignSystem/Tests/InputFieldFeatureTests.swift @@ -5,7 +5,8 @@ import Testing @Suite("InputFieldFeature") struct InputFieldFeatureTests { - @Test("21억 초과 입력 시 21억으로 캡핑하고 에러 문구를 노출한다") + @Test("21억 초과 입력 시 21억으로 캡핑하고 입력 한도 에러를 노출한다") + @MainActor func amountTextChangedCapsAtLimit() async { let store = TestStore( initialState: InputFieldFeature.State( @@ -19,13 +20,14 @@ struct InputFieldFeatureTests { await store.send(.textChanged("9999999999")) { $0.text = "2100000000" - $0.state = .error(message: "21억") + $0.state = .error(message: "*입력 한도") } await store.receive(.triggerErrorHaptic) } @Test("상한값과 동일한 입력은 정상값으로 유지한다") + @MainActor func amountTextChangedAllowsExactLimit() async { let store = TestStore( initialState: InputFieldFeature.State( @@ -41,12 +43,13 @@ struct InputFieldFeatureTests { } @Test("clear 버튼 탭 시 텍스트와 에러 상태를 함께 초기화한다") + @MainActor func clearButtonTappedResetsTextAndError() async { let store = TestStore( initialState: InputFieldFeature.State( text: "2100000000", variant: .amount(maxAmount: 2_100_000_000), - state: .error(message: "21억"), + state: .error(message: "*입력 한도"), trailing: .unitAndClear(unitText: "원") ) ) { diff --git a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift index aa696db..585172a 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift @@ -69,13 +69,14 @@ struct Page3AmountDateView: View { Text(preset.title) .pretendard(.body(.r3)) .foregroundStyle(DoriColors.grey600.color) - .padding(.horizontal, 20) + .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( RoundedRectangle(cornerRadius: 8) .stroke(DoriColors.grey300.color) ) } + .frame(maxWidth: .infinity) } } } diff --git a/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift b/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift index 8faf418..f362fbf 100644 --- a/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift +++ b/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift @@ -70,16 +70,17 @@ public struct DoriBarGraphView: View { Spacer() // 오른쪽 사람 아이콘 (받도리) - HStack(spacing: 3) { - Text("받도리") - .pretendard(.body(.sb6)) - UIAsset.Icons.iconBaddori.image - .resizable() - .frame(width: 26, height: 26) - .padding(.trailing, 4) + if receivedAmount > 0 { + HStack(spacing: 3) { + Text("받도리") + .pretendard(.body(.sb6)) + UIAsset.Icons.iconBaddori.image + .resizable() + .frame(width: 26, height: 26) + .padding(.trailing, 4) + } + .foregroundStyle(.grey500) } - .foregroundStyle(.grey500) - } } } diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift index 950dad9..cf63d1d 100644 --- a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift @@ -17,6 +17,23 @@ public extension Settings { "IPHONEOS_DEPLOYMENT_TARGET": .string(Environment.deploymentTarget), "SWIFT_VERSION": "6.0", "CLANG_ENABLE_MODULES": "YES" + ], + configurations: [ + .debug( + name: .debug, + settings: [ + "ENABLE_TESTABILITY": "YES", + "SWIFT_OPTIMIZATION_LEVEL": "-Onone" + ] + ), + .release( + name: .release, + settings: [ + "ENABLE_TESTABILITY": "NO", + "SWIFT_OPTIMIZATION_LEVEL": "-O", + "SWIFT_COMPILATION_MODE": "wholemodule" + ] + ) ] ) From bf51ea18f134836086d153a12918f938dbba51e5 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 20:46:24 +0900 Subject: [PATCH 3/5] 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 093b4d0fea8e7cb8010a122eea0618d347d72d0f Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 20:46:33 +0900 Subject: [PATCH 4/5] 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 c030bde5d3d3a87913345bd0442f45836d5ab3e2 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 18 Mar 2026 20:46:39 +0900 Subject: [PATCH 5/5] 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`, 앱 런치, 네트워크 설정이 모두 깨질 수 있다. + ### 네이밍 규칙 | 브랜치 | 워크트리 폴더명 |