diff --git a/.claude/skills/github-issue/SKILL.md b/.claude/skills/github-issue/SKILL.md
deleted file mode 100644
index ee9cf1b..0000000
--- a/.claude/skills/github-issue/SKILL.md
+++ /dev/null
@@ -1,99 +0,0 @@
----
-name: github-issue
-description: GitHub 피처 이슈를 생성한다. 피처 개요/목표/구현 범위를 대화형으로 수집하여 이슈 자동 생성.
----
-
-# GitHub 피처 이슈 생성
-
-`.github/ISSUE_TEMPLATE/피처-생성.md` 템플릿 구조에 맞는 GitHub 이슈를 대화형으로 생성한다.
-
-## 지침
-
-### 1단계: 필수 정보 수집
-
-`AskUserQuestion`으로 아래 정보를 수집한다.
-
-**첫 번째 질문 (3개 동시):**
-- 피처 이름 (필수) — 이슈 제목에 사용
-- 피처 개요 (필수) — 구현할 피처에 대한 간단한 설명
-- 목표 (필수) — 이 피처로 달성하고자 하는 것
-
-**두 번째 질문:**
-- 구현 범위 (multiSelect) — UI/UX, 아키텍처, 네트워킹, 데이터 계층 중 해당 항목 선택
-
-### 2단계: 선택 정보 수집
-
-`AskUserQuestion`으로 추가 정보를 수집한다. 사용자가 건너뛸 수 있도록 "없음" 옵션을 제공한다.
-
-- 의존성 이슈 번호 (선택) — 관련 이슈 번호 (예: #12, #15)
-- 기술 고려사항 (선택) — 특별히 주의해야 할 기술적 사항
-
-### 3단계: 이슈 본문 조립
-
-수집한 정보를 아래 마크다운 구조로 조립한다:
-
-```markdown
-## 📋 피처 개요
-{사용자 입력}
-
-## 🎯 목표
-{사용자 입력}
-
-## 📐 구현 범위
-
-### UI/UX (선택된 경우만 포함)
-- [ ] 화면 구성
-- [ ] 사용자 인터랙션
-- [ ] 애니메이션/트랜지션
-- [ ] 다크모드 지원
-- [ ] 접근성 고려사항
-
-### 아키텍처 (선택된 경우만 포함)
-- [ ] Feature 모듈 위치: `Features/[FeatureName]`
-- [ ] State 정의
-- [ ] Action 정의
-- [ ] Reducer 로직
-- [ ] 의존성 주입
-
-### 네트워킹 (선택된 경우만 포함)
-- [ ] API 엔드포인트
-- [ ] Request/Response 모델
-- [ ] 에러 핸들링
-
-### 데이터 계층 (선택된 경우만 포함)
-- [ ] 로컬 저장소 (UserDefaults/CoreData/Realm)
-- [ ] 캐싱 전략
-
-## 🔗 의존성
-{사용자 입력 또는 "없음"}
-
-## ✅ 완료 조건 (Definition of Done)
-- [ ] 코드 작성 완료
-- [ ] Unit Test 작성
-- [ ] UI Test 작성 (필요시)
-- [ ] 코드 리뷰 완료
-- [ ] QA 테스트 통과
-- [ ] 문서화 완료
-
-## 📝 기술 고려사항
-{사용자 입력 또는 "없음"}
-```
-
-### 4단계: 이슈 생성
-
-아래 명령어로 이슈를 생성한다:
-
-```bash
-gh issue create --title "[Feature] {피처 이름}" --assignee kangddong --body "{조립된 본문}"
-```
-
-- 본문은 HEREDOC(`cat <<'EOF' ... EOF`)을 사용하여 전달한다
-- 생성 완료 후 이슈 URL을 사용자에게 반환한다
-
-## 규칙
-
-- 피처 이름, 개요, 목표는 반드시 수집해야 한다
-- 구현 범위에서 선택되지 않은 섹션은 본문에서 제외한다
-- 의존성과 기술 고려사항은 선택 사항이며, 입력이 없으면 "없음"으로 표기한다
-- 완료 조건은 템플릿 기본값을 그대로 사용한다
-- 이슈 제목 형식: `[Feature] {피처 이름}`
diff --git a/.claude/skills/pr-create/SKILL.md b/.claude/skills/pr-create/SKILL.md
new file mode 100644
index 0000000..8c06c5a
--- /dev/null
+++ b/.claude/skills/pr-create/SKILL.md
@@ -0,0 +1,212 @@
+---
+name: pr-create
+description: GitHub PR을 생성한다. 템플릿을 읽고 커밋 히스토리 기반으로 본문을 자동 생성.
+---
+
+# GitHub Pull Request 생성
+
+GitHub PR 템플릿(`.github/PULL_REQUEST_TEMPLATE.md`)을 읽고, 현재 브랜치의 커밋 히스토리를 분석하여 템플릿 구조에 맞는 PR을 자동 생성한다.
+
+## 언제 사용하는가?
+
+✅ **사용해야 하는 경우:**
+- 사용자가 "PR 올려줘", "PR 만들어줘", "풀리퀘 생성" 등을 요청할 때
+- Feature 개발 완료 후 develop 브랜치로 머지하기 위한 PR 생성
+- 커밋이 완료되고 push까지 완료된 상태
+
+❌ **사용하지 않아야 하는 경우:**
+- 아직 커밋하지 않은 변경사항이 있는 경우 (먼저 커밋 필요)
+- 브랜치를 push하지 않은 경우 (먼저 push 필요)
+- hotfix나 긴급 수정으로 직접 PR 본문을 작성해야 하는 경우
+
+## 지침
+
+### 1단계: PR 템플릿 확인
+
+프로젝트에 `.github/PULL_REQUEST_TEMPLATE.md` 파일이 있는지 확인한다:
+
+```bash
+ls -la .github/PULL_REQUEST_TEMPLATE.md
+```
+
+- **템플릿이 있는 경우**: 템플릿 구조를 따라 본문 생성
+- **템플릿이 없는 경우**: 기본 포맷으로 본문 생성
+
+### 2단계: Git 정보 수집
+
+현재 브랜치의 정보를 수집한다:
+
+```bash
+# 현재 브랜치 이름
+git branch --show-current
+
+# base 브랜치와의 차이 (develop 또는 main)
+git log develop..HEAD --oneline
+# 또는
+git log main..HEAD --oneline
+
+# 변경된 파일 목록
+git diff develop...HEAD --stat
+# 또는
+git diff main...HEAD --stat
+```
+
+### 3단계: 이슈 번호 추출
+
+브랜치 이름에서 이슈 번호를 추출한다:
+
+**브랜치 네이밍 패턴**: `feature/{issue}-{desc}` 또는 `fix/{issue}-{desc}`
+
+예시:
+- `feature/16-add-dori` → 이슈 번호: `#16`
+- `fix/24-login-bug` → 이슈 번호: `#24`
+
+### 4단계: PR 제목 생성
+
+브랜치 타입과 설명을 기반으로 제목을 생성한다:
+
+| 브랜치 타입 | PR 제목 패턴 |
+|------------|-------------|
+| `feature/*` | `[Feature] {기능 설명}` |
+| `fix/*` | `[Fix] {수정 내용}` |
+| `chore/*` | `[Chore] {작업 내용}` |
+| `refactor/*` | `[Refactor] {리팩토링 내용}` |
+
+### 5단계: PR 본문 생성
+
+#### Dori-iOS 템플릿 구조
+
+```markdown
+## About this PR
+### ⚓ Related Issue
+- #{이슈번호}
+
+### 🥥 Contents
+{커밋 히스토리 기반 작업 내용 요약}
+
+**주요 변경사항:**
+- {변경사항 1}
+- {변경사항 2}
+- {변경사항 3}
+
+### 📸 Screenshot
+{UI 변경이 있는 경우 스크린샷 테이블, 없으면 "UI 변경 없음"}
+
+## Other information 🔥
+{리뷰어가 참고할 내용, 특이사항}
+```
+
+#### 본문 생성 규칙
+
+1. **Related Issue**: 브랜치에서 추출한 이슈 번호
+2. **Contents**:
+ - 커밋 메시지들을 분석하여 작업 내용 요약
+ - 주요 변경사항은 커밋 제목 기반으로 bullet point 작성
+3. **Screenshot**:
+ - Feature/UI 작업인 경우: 빈 테이블 제공 (사용자가 수동 추가)
+ - 그 외: "UI 변경 없음" 또는 섹션 제거
+4. **Other information**:
+ - 논리적 커밋 분리 여부
+ - 테스트 상태
+ - 서버 API 연동 대기 여부 등
+
+### 6단계: PR 생성 실행
+
+```bash
+gh pr create \
+ --title "{PR 제목}" \
+ --body "$(cat <<'EOF'
+{생성한 본문}
+EOF
+)" \
+ --base develop \
+ --head {현재 브랜치}
+```
+
+**주의사항:**
+- base 브랜치는 일반적으로 `develop` (Git 전략 참조)
+- hotfix는 `main`으로 직접 PR 생성 가능
+- PR 생성 후 URL을 사용자에게 반환
+
+### 7단계: PR 생성 확인
+
+```bash
+# 생성된 PR 확인
+gh pr view --web
+```
+
+## 템플릿 예시
+
+### Dori-iOS 프로젝트 PR 본문 예시
+
+```markdown
+## About this PR
+### ⚓ Related Issue
+- #16
+
+### 🥥 Contents
+경조사 내역(주도리/받도리) 추가 및 수정 기능을 TCA 아키텍처로 구현했습니다.
+
+**주요 변경사항:**
+- **네트워크 레이어**: DoriEndpoints, Requests/Responses 추가
+- **Core 모델**: DoriDomain 모델 정의, 캘린더 아이콘 추가
+- **AddDori Feature**: 3-step 상태 관리 (관계인 검색 → 방문 여부 → 상세 입력)
+- **API Client**: AddDoriAPIClient (관계인 검색, 내역 등록/수정)
+- **UI Components**: 14개 파일 (StepIndicator, PartnerSearch, DetailInput 등)
+- **테스트**: AddDoriFeatureTests (TCA TestStore 기반)
+
+**커밋 히스토리:**
+- `chore: AddDori 네트워크 레이어 추가`
+- `chore: AddDori Core/DesignSystem 업데이트`
+- `feat: AddDori 내역 추가/수정 Feature 구현`
+- `test: AddDori Reducer 테스트 추가`
+
+### 📸 Screenshot
+|Step 1: 관계인 검색|Step 2: 방문 여부|Step 3: 상세 정보|
+|--|--|--|
+|
|
|
|
+
+## Other information 🔥
+- 네트워크/Core/Feature/Test로 논리적 커밋 분리 완료
+- Mock API로 개발, 서버 API 연동 대기 중
+- TCA TestStore 기반 단위 테스트 포함
+```
+
+## 자동화 플로우
+
+```
+1. 브랜치 정보 수집
+ ↓
+2. 템플릿 읽기
+ ↓
+3. 커밋 히스토리 분석
+ ↓
+4. PR 본문 조립
+ ↓
+5. gh pr create 실행
+ ↓
+6. PR URL 반환
+```
+
+## 주의사항
+
+- **Push 확인**: PR 생성 전에 브랜치가 remote에 push되어 있어야 함
+- **base 브랜치**: 일반적으로 `develop`, hotfix는 `main`
+- **스크린샷**: UI 작업인 경우 빈 테이블 제공, 사용자가 수동으로 업로드
+- **Co-Authored-By**: 커밋에는 포함되지만 PR 본문에는 불필요
+- **Draft PR**: 작업 중인 경우 `--draft` 옵션 사용 가능
+
+## 오류 처리
+
+| 오류 | 원인 | 해결 |
+|------|------|------|
+| `head branch not found` | 브랜치를 push하지 않음 | `git push -u origin {브랜치명}` |
+| `pull request already exists` | 이미 PR이 존재 | `gh pr view`로 확인 |
+| `base branch not found` | base 브랜치 오타 | develop/main 확인 |
+
+## 확장 기능
+
+- **Draft PR**: `--draft` 옵션으로 작업 중 PR 생성
+- **Reviewer 지정**: `--reviewer {username}` 옵션
+- **Label 추가**: `--label feature,tca` 등
+- **Assignee 설정**: `--assignee @me`
diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift
index ed7267a..6f83472 100644
--- a/Projects/App/Project.swift
+++ b/Projects/App/Project.swift
@@ -16,6 +16,7 @@ let project = Project.dori(
resources: [.glob(pattern: "Resources/**", excluding: ["Resources/info.plist"])],
dependencies: [
DoriModules.onboarding.module.projectDependency,
+ DoriModules.addDori.module.projectDependency,
DoriModules.calendar.module.projectDependency,
DoriModules.history.module.projectDependency,
DoriModules.myPage.module.projectDependency,
diff --git a/Projects/App/Sources/DependencyConfiguration.swift b/Projects/App/Sources/DependencyConfiguration.swift
new file mode 100644
index 0000000..d74fcb0
--- /dev/null
+++ b/Projects/App/Sources/DependencyConfiguration.swift
@@ -0,0 +1,8 @@
+//
+// DependencyConfiguration.swift
+// DoriApp
+//
+// Created by 강동영 on 2/12/26.
+// Copyright © 2026 com.arex. All rights reserved.
+//
+
diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift
index adcf400..5eaf3e0 100644
--- a/Projects/App/Sources/DoriApp.swift
+++ b/Projects/App/Sources/DoriApp.swift
@@ -12,6 +12,7 @@ import DoriNetwork
import DoriNetworkImpl
import FeatureMyPage
import FeatureOnboarding
+import FeatureAddDori
import PlatformKakaoAuth
import PlatformKeychain
@@ -43,6 +44,9 @@ struct DoriApp: App {
networkService: networkService,
tokenStore: tokenStore
)
+
+ $0.addDoriAPIClient = .live(networkService: networkService)
+
$0.myPageAPIClient = .live(
networkService: networkService,
tokenStore: tokenStore
diff --git a/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift b/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift
new file mode 100644
index 0000000..356bf12
--- /dev/null
+++ b/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift
@@ -0,0 +1,59 @@
+//
+// DoriDomain.swift
+// DoriCore
+//
+// Created by 강동영 on 2/19/26.
+// Copyright © 2026 com.arex. All rights reserved.
+//
+
+import Foundation
+
+public struct Dori: Equatable, Sendable {
+ public let doriId: Int64
+ public let userId: Int64
+ public let partnerId: Int64
+ public let direction: Direction
+ public let partnerName: String
+ public let relationship: Relationship
+ public let eventType: EventType
+ public let amount: Int32
+ public let eventDate: String
+ public let isVisited: Bool
+ public let memo: String
+ public let createdAt: String
+
+ public init(
+ doriId: Int64,
+ userId: Int64,
+ partnerId: Int64,
+ direction: Direction,
+ partnerName: String,
+ relationship: Relationship,
+ eventType: EventType,
+ amount: Int32,
+ eventDate: String,
+ isVisited: Bool,
+ memo: String,
+ createdAt: String
+ ) {
+ self.doriId = doriId
+ self.userId = userId
+ self.partnerId = partnerId
+ self.direction = direction
+ self.partnerName = partnerName
+ self.relationship = relationship
+ self.eventType = eventType
+ self.amount = amount
+ self.eventDate = eventDate
+ self.isVisited = isVisited
+ self.memo = memo
+ self.createdAt = createdAt
+ }
+}
+
+public extension Dori {
+ enum Direction: String, Equatable, Sendable {
+ case `in` = "IN"
+ case out = "OUT"
+ }
+}
diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/iconCalendar.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/iconCalendar.imageset/Contents.json
new file mode 100644
index 0000000..a9dd790
--- /dev/null
+++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/iconCalendar.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "iconCalendar.svg",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/iconCalendar.imageset/iconCalendar.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/iconCalendar.imageset/iconCalendar.svg
new file mode 100644
index 0000000..9735b49
--- /dev/null
+++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/iconCalendar.imageset/iconCalendar.svg
@@ -0,0 +1,6 @@
+
diff --git a/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift b/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift
index 2c3d969..eb651c0 100644
--- a/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift
+++ b/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift
@@ -9,15 +9,18 @@ import SwiftUI
public struct PrimaryButton: View {
private let titleKey: String
- private let titleStyle: TypoSemantic
+ private var titleStyle: TypoSemantic
private let action: @MainActor () -> Void
+ private var isEnabled: Bool = true
private var foregroundColor: Color = UIAsset.Colors.doriWhite.color
private var backgroundColor: Color = UIAsset.Colors.main.color
+ private var strokeColor: Color? = nil
private var cornerRadius: CGFloat = 10
public var body: some View {
Button {
+ guard isEnabled else { return }
action()
} label: {
Text(titleKey)
@@ -27,6 +30,7 @@ public struct PrimaryButton: View {
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(backgroundColor)
+ .stroke(strokeColor ?? .clear, style: .init(lineWidth: 1))
)
}
}
@@ -43,6 +47,12 @@ public struct PrimaryButton: View {
}
public extension PrimaryButton {
+ func pretendard(_ semantic: TypoSemantic) -> Self {
+ var button = self
+ button.titleStyle = semantic
+ return button
+ }
+
func foregroundColor(_ color: Color) -> Self {
var button = self
button.foregroundColor = color
@@ -72,4 +82,25 @@ public extension PrimaryButton {
button.cornerRadius = value
return button
}
+
+ func strokeColor(_ asset: UIAsset.Colors) -> Self {
+ var button = self
+ button.strokeColor = asset.color
+ return button
+ }
+
+ func isEnable(_ isEnable: Bool) -> some View {
+ print("isEnable: \(isEnable)")
+ var button = self
+ let backgroundColor = isEnable ? UIAsset.Colors.main.color : UIAsset.Colors.grey100.color
+ let foregroundColor = isEnable ? UIAsset.Colors.doriWhite.color : UIAsset.Colors.grey500.color
+
+ button.backgroundColor = backgroundColor
+ button.foregroundColor = foregroundColor
+ button.isEnabled = isEnable
+
+ return button
+ .disabled(!isEnable)
+ }
+
}
diff --git a/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift b/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift
new file mode 100644
index 0000000..9dec70b
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift
@@ -0,0 +1,247 @@
+//
+// AddDoriAPIClient.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import Foundation
+import ComposableArchitecture
+import DoriNetwork
+
+public struct AddDoriAPIClient: Sendable {
+ public var searchPartners: @Sendable (_ query: String) async throws -> [DoriResponsesDTO]
+ public var createDori: @Sendable (DoriPostRequest) async throws -> DoriResponsesDTO
+ public var updateDori: @Sendable (_ doriId: Int64, _ request: DoriUpdateRequest) async throws -> DoriResponsesDTO
+
+ public init(
+ searchPartners: @escaping @Sendable (_ query: String) async throws -> [DoriResponsesDTO],
+ createDori: @escaping @Sendable (DoriPostRequest) async throws -> DoriResponsesDTO,
+ updateDori: @escaping @Sendable (_ doriId: Int64, _ request: DoriUpdateRequest) async throws -> DoriResponsesDTO
+ ) {
+ self.searchPartners = searchPartners
+ self.createDori = createDori
+ self.updateDori = updateDori
+ }
+}
+
+private enum AddDoriAPIClientError: LocalizedError {
+ case unconfigured
+ case invalidResponse
+ case backendError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .unconfigured:
+ return "AddDoriAPIClient가 구성되지 않았습니다."
+ case .invalidResponse:
+ return "서버 응답이 올바르지 않습니다."
+ case .backendError(let message):
+ return message
+ }
+ }
+}
+
+extension AddDoriAPIClient: DependencyKey {
+ public static let liveValue = Self(
+ searchPartners: { _ in throw AddDoriAPIClientError.unconfigured },
+ createDori: { _ in throw AddDoriAPIClientError.unconfigured },
+ updateDori: { _, _ in throw AddDoriAPIClientError.unconfigured }
+ )
+
+ public static let previewValue = Self(
+ searchPartners: { query in
+ [
+ DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 1,
+ direction: "주도리",
+ partnerName: "박수진",
+ relationship: "친구",
+ eventType: "결혼식",
+ amount: 100_000,
+ eventDate: "2026-01-10",
+ isVisited: true,
+ memo: "",
+ createdAt: "2026-01-10"
+ ),
+ DoriResponsesDTO(
+ doriId: 2,
+ userId: 1,
+ partnerId: 2,
+ direction: "주도리",
+ partnerName: "박민준",
+ relationship: "가족",
+ eventType: "장례식",
+ amount: 200_000,
+ eventDate: "2025-08-20",
+ isVisited: true,
+ memo: "많이 힘드셨을텐데",
+ createdAt: "2025-08-20"
+ ),
+ DoriResponsesDTO(
+ doriId: 3,
+ userId: 1,
+ partnerId: 3,
+ direction: "받도리",
+ partnerName: "김철수",
+ relationship: "직장동료",
+ eventType: "돌잔치",
+ amount: 50_000,
+ eventDate: "2025-11-05",
+ isVisited: false,
+ memo: "",
+ createdAt: "2025-11-05"
+ ),
+ DoriResponsesDTO(
+ doriId: 4,
+ userId: 1,
+ partnerId: 4,
+ direction: "받도리",
+ partnerName: "김영희",
+ relationship: "친척",
+ eventType: "생신",
+ amount: 100_000,
+ eventDate: "2026-02-01",
+ isVisited: true,
+ memo: "",
+ createdAt: "2026-02-01"
+ ),
+ ].filter { $0.partnerName.contains(query) }
+ },
+ createDori: { request in
+ DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 0,
+ direction: request.direction,
+ partnerName: request.partnerName,
+ relationship: request.relationship,
+ eventType: request.eventType,
+ amount: request.amount,
+ eventDate: request.eventDate,
+ isVisited: request.isVisited,
+ memo: request.memo ?? "",
+ createdAt: "2026-02-15"
+ )
+ },
+ updateDori: { doriId, request in
+ DoriResponsesDTO(
+ doriId: doriId,
+ userId: 1,
+ partnerId: 1,
+ direction: request.direction ?? "주도리",
+ partnerName: "김철수",
+ relationship: "친구",
+ eventType: request.eventType ?? "결혼식",
+ amount: request.amount ?? 50_000,
+ eventDate: request.eventDate ?? "2026-02-15",
+ isVisited: request.isVisited ?? true,
+ memo: request.memo ?? "",
+ createdAt: "2026-02-15"
+ )
+ }
+ )
+
+ public static let testValue = Self(
+ searchPartners: { _ in [] },
+ createDori: { request in
+ DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 0,
+ direction: request.direction,
+ partnerName: request.partnerName,
+ relationship: request.relationship,
+ eventType: request.eventType,
+ amount: request.amount,
+ eventDate: request.eventDate,
+ isVisited: request.isVisited,
+ memo: request.memo ?? "",
+ createdAt: "2026-02-15"
+ )
+ },
+ updateDori: { _, _ in
+ DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 1,
+ direction: "주도리",
+ partnerName: "테스트",
+ relationship: "친구",
+ eventType: "결혼식",
+ amount: 50_000,
+ eventDate: "2026-02-15",
+ isVisited: true,
+ memo: "",
+ createdAt: "2026-02-15"
+ )
+ }
+ )
+}
+
+public extension DependencyValues {
+ var addDoriAPIClient: AddDoriAPIClient {
+ get { self[AddDoriAPIClient.self] }
+ set { self[AddDoriAPIClient.self] = newValue }
+ }
+}
+
+public extension AddDoriAPIClient {
+ static func live(networkService: any NetworkService) -> Self {
+ Self(
+ searchPartners: { query in
+ let endpoint = SearchPartnersEndpoint(query: query)
+ let response = try await networkService.request(
+ endpoint,
+ responseType: SuccessResponse<[DoriResponsesDTO]>.self
+ )
+
+ if let apiError = response.error {
+ throw AddDoriAPIClientError.backendError(apiError.message ?? apiError.code)
+ }
+
+ guard response.success, let data = response.data else {
+ throw AddDoriAPIClientError.invalidResponse
+ }
+
+ return data
+ },
+ createDori: { request in
+ let endpoint = CreateDoriEndpoint(request: request)
+ let response = try await networkService.request(
+ endpoint,
+ responseType: SuccessResponse.self
+ )
+
+ if let apiError = response.error {
+ throw AddDoriAPIClientError.backendError(apiError.message ?? apiError.code)
+ }
+
+ guard response.success, let data = response.data else {
+ throw AddDoriAPIClientError.invalidResponse
+ }
+
+ return data
+ },
+ updateDori: { doriId, request in
+ let endpoint = UpdateDoriEndpoint(doriId: doriId, request: request)
+ let response = try await networkService.request(
+ endpoint,
+ responseType: SuccessResponse.self
+ )
+
+ if let apiError = response.error {
+ throw AddDoriAPIClientError.backendError(apiError.message ?? apiError.code)
+ }
+
+ guard response.success, let data = response.data else {
+ throw AddDoriAPIClientError.invalidResponse
+ }
+
+ return data
+ }
+ )
+ }
+}
diff --git a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift
new file mode 100644
index 0000000..06a0e7e
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift
@@ -0,0 +1,397 @@
+//
+// AddDoriFeature.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import Foundation
+import ComposableArchitecture
+import DoriCore
+import DoriNetwork
+
+@Reducer
+public struct AddDoriFeature {
+ public init() {}
+
+ // MARK: - Mode
+
+ public enum Mode: Equatable, Sendable {
+ case create
+ case edit(DoriResponsesDTO)
+ }
+
+ // MARK: - State
+
+ @ObservableState
+ public struct State: Equatable, Sendable {
+ public var mode: Mode
+ public var currentPage: Int = 0
+
+ // Page 1: 이름/구분
+ public var transactionType: TransactionType = .given
+ public var searchQuery: String = ""
+ public var searchResults: [DoriResponsesDTO] = []
+ public var selectedPartner: DoriResponsesDTO? = nil
+ public var isSearching: Bool = false
+
+ // Page 2: 관계/경조사
+ public var selectedRelationship: Relationship = .friend
+ public var customRelationship: String = ""
+ public var selectedEventType: EventType = .wedding
+ public var customEventType: String = ""
+
+ // Page 3: 금액/날짜
+ public var amountText: String = ""
+ public var eventDate: Date = .init()
+ public var isDatePickerVisible: Bool = false
+ public var isVisited: Visited = .yes
+ public var memo: String = ""
+
+ public var isSubmitting: Bool = false
+
+ // MARK: - Validation
+
+ public var isPage1Valid: Bool {
+ selectedPartner != nil || !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+
+ public var isPage2Valid: Bool {
+ let hasRelationship = (selectedRelationship != Relationship.other || !customRelationship.trimmingCharacters(in: .whitespaces).isEmpty)
+ let hasEventType = (selectedEventType != EventType.other || !customEventType.trimmingCharacters(in: .whitespaces).isEmpty)
+
+ print("hasRelationship: \(hasRelationship), hasEventType: \(hasEventType)")
+ return hasRelationship && hasEventType
+ }
+
+ public var isPage3Valid: Bool {
+ let digits = amountText.filter(\.isNumber)
+ guard let amount = Int(digits), amount > 0 else { return false }
+ return isVisited.boolValue
+ }
+
+ // MARK: - Computed
+
+ public var partnerName: String {
+ selectedPartner?.partnerName ?? searchQuery.trimmingCharacters(in: .whitespaces)
+ }
+
+ public var resolvedRelationship: String {
+ if selectedRelationship == Relationship.other {
+ return customRelationship.trimmingCharacters(in: .whitespaces)
+ }
+ return selectedRelationship.rawValue
+ }
+
+ public var resolvedEventType: String {
+ if selectedEventType == EventType.other {
+ return customEventType.trimmingCharacters(in: .whitespaces)
+ }
+ return selectedEventType.rawValue
+ }
+
+ public var navigationTitle: String {
+ switch mode {
+ case .create: "내역 추가"
+ case .edit: "내역 수정"
+ }
+ }
+
+ // MARK: - Init
+
+ public init(mode: Mode = .create) {
+ self.mode = mode
+
+ if case let .edit(dori) = mode {
+ self.transactionType = dori.direction == TransactionType.given.rawValue ? .given : .received
+ self.searchQuery = dori.partnerName
+
+ let knownRelationships = Relationship.allCases.map(\.rawValue)
+ if knownRelationships.contains(dori.relationship) {
+ self.selectedRelationship = Relationship(rawValue: dori.relationship) ?? .other
+ } else {
+ self.selectedRelationship = Relationship.other
+ self.customRelationship = dori.relationship
+ }
+
+ let knownEventTypes = EventType.allCases.map(\.rawValue)
+ if knownEventTypes.contains(dori.eventType) {
+ self.selectedEventType = EventType(rawValue: dori.eventType) ?? .other
+ } else {
+ self.selectedEventType = EventType.other
+ self.customEventType = dori.eventType
+ }
+
+ self.amountText = Int(dori.amount).decimalFormatted
+ self.isVisited = dori.isVisited ? .yes : .no
+ self.memo = dori.memo
+ }
+ }
+ }
+
+ // MARK: - Action
+
+ public enum Action: Equatable, Sendable {
+ // Navigation
+ case nextPageTapped
+ case previousPageTapped
+
+ // Page 1
+ case transactionTypeChanged(TransactionType)
+ case searchQueryChanged(String)
+ case searchResponse([DoriResponsesDTO])
+ case partnerSelected(DoriResponsesDTO?)
+ case clearSearchTapped
+
+ // Page 2
+ case relationshipSelected(Relationship)
+ case customRelationshipChanged(String)
+ case eventTypeSelected(EventType)
+ case customEventTypeChanged(String)
+
+ // Page 3
+ case amountTextChanged(String)
+ case addAmountTapped(Int)
+ case datePickerToggled
+ case eventDateChanged(Date)
+ case isVisitedChanged(Visited)
+ case memoChanged(String)
+
+ // Submit
+ case submitTapped
+ case submitResponse(Result)
+
+ // Delegate
+ case delegate(Delegate)
+
+ public enum Delegate: Equatable, Sendable {
+ case doriCreated(DoriResponsesDTO)
+ case doriUpdated(DoriResponsesDTO)
+ case dismissed
+ }
+ }
+
+ public struct SubmitError: Error, Equatable, Sendable {
+ public let message: String
+
+ public init(message: String) {
+ self.message = message
+ }
+ }
+
+ static let maxAmount = Int(Int32.max) // 2,147,483,647 (약 21.47억)
+
+ private enum CancelID {
+ case search
+ }
+
+ // MARK: - Dependencies
+
+ @Dependency(\.continuousClock) var clock
+ @Dependency(\.addDoriAPIClient) var apiClient
+
+ // MARK: - Reducer
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ // MARK: Navigation
+ case .nextPageTapped:
+ if state.currentPage < 2 {
+ state.currentPage += 1
+ }
+ return .none
+
+ case .previousPageTapped:
+ if state.currentPage > 0 {
+ state.currentPage -= 1
+ }
+ return .none
+
+ // MARK: Page 1
+ case let .transactionTypeChanged(type):
+ state.transactionType = type
+ return .none
+
+ case let .searchQueryChanged(query):
+ state.searchQuery = String(query.prefix(10))
+ print("state.searchQuery: \(state.searchQuery)")
+ state.selectedPartner = nil
+
+ guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
+ state.searchResults = []
+ state.isSearching = false
+ return .cancel(id: CancelID.search)
+ }
+
+ state.isSearching = true
+ let searchPartners = apiClient.searchPartners
+ let clock = self.clock
+ return .run { send in
+ try await clock.sleep(for: .milliseconds(300))
+ let results = try await searchPartners(query)
+ await send(.searchResponse(results))
+ }
+ .cancellable(
+ id: CancelID.search,
+ cancelInFlight: true
+ )
+
+ case let .searchResponse(results):
+ state.searchResults = results
+ state.isSearching = false
+ return .none
+
+ case let .partnerSelected(partner):
+ state.selectedPartner = partner
+ if let partner {
+ state.searchQuery = partner.partnerName
+ state.searchResults = []
+
+ let knownRelationships = Relationship.allCases.map(\.rawValue)
+ if knownRelationships.contains(partner.relationship) {
+ state.selectedRelationship = Relationship(rawValue: partner.relationship) ?? .other
+ } else {
+ state.selectedRelationship = Relationship.other
+ state.customRelationship = partner.relationship
+ }
+ }
+ return .none
+
+ case .clearSearchTapped:
+ state.searchQuery = ""
+ state.searchResults = []
+ state.selectedPartner = nil
+ return .cancel(id: CancelID.search)
+
+ // MARK: Page 2
+ case let .relationshipSelected(relationship):
+ state.selectedRelationship = relationship
+ if relationship != Relationship.other {
+ state.customRelationship = ""
+ }
+ return .none
+
+ case let .customRelationshipChanged(text):
+ state.customRelationship = String(text.prefix(10))
+ return .none
+
+ case let .eventTypeSelected(eventType):
+ state.selectedEventType = eventType
+ if eventType != EventType.other {
+ state.customEventType = ""
+ }
+ return .none
+
+ case let .customEventTypeChanged(text):
+ state.customEventType = String(text.prefix(10))
+ 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 = ""
+ }
+ 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
+
+ case .datePickerToggled:
+ state.isDatePickerVisible.toggle()
+ return .none
+
+ case let .eventDateChanged(date):
+ state.eventDate = date
+ return .none
+
+ case let .isVisitedChanged(visited):
+ state.isVisited = visited
+ return .none
+
+ case let .memoChanged(text):
+ state.memo = String(text.prefix(40))
+ return .none
+
+ // MARK: Submit
+ case .submitTapped:
+ guard !state.isSubmitting else { return .none }
+ state.isSubmitting = true
+
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd"
+ let eventDateString = dateFormatter.string(from: state.eventDate)
+
+ switch state.mode {
+ case .create:
+ let request = DoriPostRequest(
+ partnerId: 0,
+ direction: state.transactionType.rawValue,
+ partnerName: state.partnerName,
+ relationship: state.resolvedRelationship,
+ eventType: state.resolvedEventType,
+ amount: Int32(state.amountText.filter(\.isNumber)) ?? 0,
+ eventDate: eventDateString,
+ isVisited: state.isVisited.boolValue,
+ memo: state.memo.isEmpty ? nil : state.memo
+ )
+ let createDori = apiClient.createDori
+ return .run { send in
+ do {
+ let response = try await createDori(request)
+ await send(.submitResponse(.success(response)))
+ } catch {
+ await send(.submitResponse(.failure(SubmitError(message: error.localizedDescription))))
+ }
+ }
+
+ case let .edit(existing):
+ let request = DoriUpdateRequest(
+ direction: state.transactionType.rawValue,
+ eventType: state.resolvedEventType,
+ amount: Int32(state.amountText.filter(\.isNumber)) ?? 0,
+ eventDate: eventDateString,
+ isVisited: state.isVisited.boolValue,
+ memo: state.memo.isEmpty ? nil : state.memo
+ )
+ let doriId = existing.doriId
+ let updateDori = apiClient.updateDori
+ return .run { send in
+ do {
+ let response = try await updateDori(
+ doriId,
+ request
+ )
+ await send(.submitResponse(.success(response)))
+ } catch {
+ await send(.submitResponse(.failure(SubmitError(message: error.localizedDescription))))
+ }
+ }
+ }
+
+ case let .submitResponse(.success(response)):
+ state.isSubmitting = false
+ switch state.mode {
+ case .create:
+ return .send(.delegate(.doriCreated(response)))
+ case .edit:
+ return .send(.delegate(.doriUpdated(response)))
+ }
+
+ case .submitResponse(.failure):
+ state.isSubmitting = false
+ return .none
+
+ case .delegate:
+ return .none
+ }
+ }
+ }
+}
diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift
new file mode 100644
index 0000000..3830fc9
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift
@@ -0,0 +1,94 @@
+//
+// AddDoriView.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import SwiftUI
+import ComposableArchitecture
+import DoriDesignSystem
+
+public struct AddDoriView: View {
+ @Bindable var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ VStack(spacing: 32) {
+ pageIndicator
+
+ pageContent
+ .animation(
+ .easeInOut(duration: 0.3),
+ value: store.currentPage
+ )
+ }
+ .navigationTitle(store.state.navigationTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button {
+ store.send(.previousPageTapped)
+ } label: {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 17, weight: .semibold))
+ }
+ }
+ }
+ }
+
+ private var pageIndicator: some View {
+ HStack {
+ PageIndicator(
+ count: 3,
+ currentIndex: Binding(
+ get: { store.currentPage },
+ set: { _ in }
+ )
+ )
+ .padding(.top, 24)
+ .padding(.leading, 16)
+
+ Spacer()
+ }
+ }
+
+ @ViewBuilder
+ private var pageContent: some View {
+ switch store.currentPage {
+ case 0:
+ Page1NameTypeView(store: store)
+ .transition(.asymmetric(
+ insertion: .move(edge: .trailing),
+ removal: .move(edge: .leading)
+ ))
+ case 1:
+ Page2RelationEventView(store: store)
+ .transition(.asymmetric(
+ insertion: .move(edge: .trailing),
+ removal: .move(edge: .leading)
+ ))
+ case 2:
+ Page3AmountDateView(store: store)
+ .transition(.asymmetric(
+ insertion: .move(edge: .trailing),
+ removal: .move(edge: .leading)
+ ))
+ default:
+ EmptyView()
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ AddDoriView(
+ store: Store(initialState: AddDoriFeature.State(mode: .create)) {
+ AddDoriFeature()
+ }
+ )
+ }
+}
diff --git a/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift b/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift
new file mode 100644
index 0000000..3f94b45
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift
@@ -0,0 +1,44 @@
+//
+// AddDoriModifiers.swift
+// DoriFeature
+//
+// Created by 강동영 on 2/19/26.
+// Copyright © 2026 com.arex. All rights reserved.
+//
+
+import SwiftUI
+import DoriDesignSystem
+
+struct AddDoriSectionTitleStyleModifier: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .font(.pretendard(.subtitle(.m2)))
+ .foregroundStyle(.grey600)
+ }
+}
+
+struct RoundedStyleModifier: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .stroke(DoriColors.grey300.color)
+ )
+ }
+}
+
+extension View {
+ /// H padding: 16, V padding: 12, r: 10
+ func roundedStyle() -> some View {
+ modifier(RoundedStyleModifier())
+ }
+
+}
+
+extension Text {
+ func addDoriSectionTitleStyle() -> some View {
+ modifier(AddDoriSectionTitleStyleModifier())
+ }
+}
diff --git a/Projects/Feature/AddDori/Sources/Views/DoriSegmentGridWithMemo.swift b/Projects/Feature/AddDori/Sources/Views/DoriSegmentGridWithMemo.swift
new file mode 100644
index 0000000..77bbb13
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Views/DoriSegmentGridWithMemo.swift
@@ -0,0 +1,168 @@
+//
+// DoriSegmentGridWithMemo.swift
+// DoriFeature
+//
+// Created by 강동영 on 2/19/26.
+// Copyright © 2026 com.arex. All rights reserved.
+//
+
+import SwiftUI
+import DoriDesignSystem
+import DoriCore
+
+struct DoriSegmentOption: Identifiable {
+ enum Role { case normal, other }
+
+ let id: ID
+ let title: String
+ let role: Role
+}
+
+struct DoriSegmentGridWithMemo: View {
+ let options: [DoriSegmentOption]
+ @Binding var selection: ID
+ @Binding var memo: String
+
+ private let columns: Int = 2
+ private var otherID: ID? { options.first(where: { $0.role == .other })?.id }
+ private var isOtherSelected: Bool { selection == otherID }
+
+ init(
+ options: [DoriSegmentOption],
+ selection: Binding,
+ memo: Binding = .constant("")
+ ) {
+ self.options = options
+ self._selection = selection
+ self._memo = memo
+ }
+
+ var body: some View {
+ VStack(spacing: 12) {
+ grid
+ if isOtherSelected { memoField }
+ }
+ .onChange(of: memo) { _, newValue in
+ syncSelectionWithMemo(newValue)
+ }
+ .onChange(of: selection) { _, newSelection in
+ syncMemoWithSelection(newSelection)
+ }
+ }
+
+ private var grid: some View {
+ Grid(horizontalSpacing: 16, verticalSpacing: 10) {
+ ForEach(chunked(options, size: columns).indices, id: \.self) { row in
+ let rowItems = chunked(options, size: columns)[row]
+
+ GridRow {
+ ForEach(rowItems) { option in
+ DoriSegmentButton(option: option, selection: $selection)
+ }
+
+ // 만약 마지막 row가 1개만 있는 경우(스펙상 없다고 했지만) 빈칸 채우기
+ if rowItems.count < columns {
+ Color.clear
+ .frame(maxWidth: .infinity, minHeight: 53)
+ }
+ }
+ }
+ }
+ }
+
+ private var memoField: some View {
+ DoriTextField("관계를 입력하세요. (10자)", memo: $memo)
+ }
+
+ // memo가 있으면 other를 강제 선택
+ private func syncSelectionWithMemo(_ memo: String) {
+ let trimmed = memo.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ guard let otherID else { return }
+ if selection != otherID {
+ selection = otherID
+ }
+ }
+
+ // other가 아닌 것을 선택하면 memo는 비움
+ private func syncMemoWithSelection(_ selection: ID?) {
+ guard let selection else { return }
+ guard let otherID else { return }
+ if selection != otherID, !memo.isEmpty {
+ memo = ""
+ }
+ }
+}
+
+// helper
+private func chunked(_ array: [T], size: Int) -> [[T]] {
+ guard size > 0 else { return [] }
+ return stride(from: 0, to: array.count, by: size).map {
+ Array(array[$0..: View {
+ let option: DoriSegmentOption
+ @Binding var selection: ID
+
+ var isOn: Bool { selection == option.id }
+
+ var body: some View {
+ PrimaryButton(title: option.title) {
+ selection = option.id
+ }
+ .applyDoriSegmentStyle(isOn: isOn)
+ }
+}
+
+fileprivate extension PrimaryButton {
+ func applyDoriSegmentStyle(isOn: Bool) -> Self {
+ isOn ? self.doriSelected() : self.doriUnselected()
+ }
+}
+
+fileprivate extension PrimaryButton {
+ func doriSelected() -> Self {
+ self
+ .pretendard(.body(.sb3))
+ .backgroundColor(.main)
+ .foregroundColor(.doriWhite)
+
+ }
+
+ func doriUnselected() -> Self {
+ self
+ .pretendard(.body(.r3))
+ .backgroundColor(.doriWhite)
+ .foregroundColor(.grey500)
+ .strokeColor(.grey300)
+ }
+}
+
+extension Relationship {
+ func toSegmentOptions() -> DoriSegmentOption {
+ if self == .other {
+ DoriSegmentOption(id: self, title: self.rawValue, role: .other)
+ } else {
+ DoriSegmentOption(id: self, title: self.rawValue, role: .normal)
+ }
+ }
+}
+
+extension EventType {
+ func toSegmentOptions() -> DoriSegmentOption {
+ if self == .other {
+ DoriSegmentOption(id: self, title: self.rawValue, role: .other)
+ } else {
+ DoriSegmentOption(id: self, title: self.rawValue, role: .normal)
+ }
+ }
+}
+
+extension Visited {
+ func toSegmentOptions() -> DoriSegmentOption {
+ DoriSegmentOption(id: self, title: self.rawValue, role: .normal)
+ }
+}
+
diff --git a/Projects/Feature/AddDori/Sources/Views/DoriTextField.swift b/Projects/Feature/AddDori/Sources/Views/DoriTextField.swift
new file mode 100644
index 0000000..ef4d661
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Views/DoriTextField.swift
@@ -0,0 +1,54 @@
+//
+// DoriTextField.swift
+// DoriFeature
+//
+// Created by 강동영 on 2/19/26.
+// Copyright © 2026 com.arex. All rights reserved.
+//
+
+import SwiftUI
+import DoriDesignSystem
+
+struct DoriTextField: View {
+ @Binding var memo: String
+
+ private let placeholder: String
+ private let maxLength: Int
+
+ init(
+ _ placeholder: String,
+ memo: Binding,
+ maxLength: Int = 10
+ ) {
+ self._memo = memo
+ self.placeholder = placeholder
+ self.maxLength = maxLength
+ }
+ 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)
+
+ }
+ .frame(height: 46)
+ }
+}
+
+#Preview {
+ @Previewable @State var memo: String = ""
+
+ DoriTextField("plz text", memo: $memo)
+}
diff --git a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift
new file mode 100644
index 0000000..1405a03
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift
@@ -0,0 +1,184 @@
+//
+// Page1NameTypeView.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import SwiftUI
+import ComposableArchitecture
+import DoriDesignSystem
+import DoriCore
+import DoriNetwork
+
+struct Page1NameTypeView: View {
+ @Bindable var store: StoreOf
+
+ private let options2x2: [DoriSegmentOption] = [
+ .init(id: .given, title: TransactionType.given.rawValue, role: .normal),
+ .init(id: .received, title: TransactionType.received.rawValue, role: .normal),
+ ]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 32) {
+ // 내역 구분
+ VStack(alignment: .leading, spacing: 10) {
+ Text("내역 구분")
+ .addDoriSectionTitleStyle()
+
+ DoriSegmentGridWithMemo(
+ options: options2x2,
+ selection: $store.transactionType.sending(\.transactionTypeChanged)
+ )
+ }
+
+ // 이름
+ VStack(alignment: .leading, spacing: 10) {
+ Text("이름")
+ .addDoriSectionTitleStyle()
+
+ HStack {
+ TextField(
+ "상대방 이름을 입력하세요. (10자)",
+ text: Binding(
+ get: { store.searchQuery },
+ set: { store.send(.searchQueryChanged(String($0.prefix(10)))) }
+ )
+ )
+ .pretendard(.body(.sb3))
+
+ if !store.searchQuery.isEmpty {
+ Button {
+ store.send(.clearSearchTapped)
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(DoriColors.grey400.color)
+ }
+ }
+ }
+ .roundedStyle()
+
+ // 검색 결과
+ if !store.searchResults.isEmpty {
+ searchResultsList
+ }
+ }
+
+ Spacer()
+
+ // 다음 버튼
+ PrimaryButton(title: "다음") {
+ store.send(.nextPageTapped)
+ }
+ .isEnable(store.isPage1Valid)
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 20)
+ }
+
+ private var searchResultsList: some View {
+ ScrollView {
+ LazyVStack(spacing: 34) {
+ ForEach(
+ Array(store.searchResults.enumerated()),
+ id: \.offset
+ ) { _, partner in
+ PartnerSearchResultRow(searchQuery: $store.searchQuery.sending(\.searchQueryChanged), partner: partner)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ store.send(.partnerSelected(partner))
+ }
+ }
+ }
+ }
+ .frame(maxHeight: 286)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 4)
+ .roundedStyle()
+ }
+}
+
+#Preview("검색 결과 있음") {
+ let state: AddDoriFeature.State = {
+ var s = AddDoriFeature.State(mode: .create)
+ s.searchQuery = ""
+ s.searchResults = [
+ DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 1,
+ direction: "주도리",
+ partnerName: "박수진수진수진수진수",
+ relationship: "친구야친구야",
+ eventType: "결혼식",
+ amount: 100_000,
+ eventDate: "2024-05-12",
+ isVisited: true,
+ memo: "",
+ createdAt: "2026-05-12"
+ ),
+ DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 1,
+ direction: "주도리",
+ partnerName: "박수진",
+ relationship: "친구",
+ eventType: "결혼식",
+ amount: 100_000,
+ eventDate: "2024-05-12",
+ isVisited: true,
+ memo: "",
+ createdAt: "2026-05-12"
+ ),
+ DoriResponsesDTO(
+ doriId: 2,
+ userId: 1,
+ partnerId: 2,
+ direction: "받도리",
+ partnerName: "박지민",
+ relationship: "직장동료",
+ eventType: "돌잔치",
+ amount: 50_000,
+ eventDate: "2025-01-15",
+ isVisited: false,
+ memo: "",
+ createdAt: "2026-01-15"
+ ),
+ DoriResponsesDTO(
+ doriId: 3,
+ userId: 1,
+ partnerId: 3,
+ direction: "주도리",
+ partnerName: "박민준",
+ relationship: "가족",
+ eventType: "장례식",
+ amount: 200_000,
+ eventDate: "2024-08-20",
+ isVisited: true,
+ memo: "많이 힘드셨을텐데",
+ createdAt: "2026-08-20"
+ ),
+ ]
+ return s
+ }()
+
+ NavigationStack {
+ Page1NameTypeView(
+ store: Store(initialState: state) {
+ AddDoriFeature()
+ }
+ )
+ }
+}
+
+#Preview("빈 상태") {
+ NavigationStack {
+ Page1NameTypeView(
+ store: Store(initialState: AddDoriFeature.State(mode: .create)) {
+ AddDoriFeature()
+ }
+ )
+ }
+}
+
diff --git a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift
new file mode 100644
index 0000000..a792d44
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift
@@ -0,0 +1,70 @@
+//
+// Page2RelationEventView.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import SwiftUI
+import ComposableArchitecture
+import DoriDesignSystem
+import DoriCore
+
+struct Page2RelationEventView: View {
+ @Bindable var store: StoreOf
+
+ @State private var memo: String = ""
+
+ private let options2x2: [DoriSegmentOption] = Relationship.allCases.map { $0.toSegmentOptions() }
+
+ private let options3x2: [DoriSegmentOption] = EventType.allCases.map { $0.toSegmentOptions() }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 32) {
+ // 관계
+ VStack(alignment: .leading, spacing: 12) {
+ Text("관계")
+ .addDoriSectionTitleStyle()
+
+ DoriSegmentGridWithMemo(
+ options: options2x2,
+ selection: $store.selectedRelationship.sending(\.relationshipSelected),
+ memo: $store.customRelationship.sending(\.customRelationshipChanged)
+ )
+ }
+
+ // 경조사
+ VStack(alignment: .leading, spacing: 12) {
+ Text("경조사")
+ .addDoriSectionTitleStyle()
+
+ DoriSegmentGridWithMemo(
+ options: options3x2,
+ selection: $store.selectedEventType.sending(\.eventTypeSelected),
+ memo: $store.customEventType.sending(\.customEventTypeChanged)
+ )
+ }
+ }
+
+ Spacer()
+
+ // 다음 버튼
+ PrimaryButton(title: "다음") {
+ store.send(.nextPageTapped)
+ }
+ .isEnable(store.isPage2Valid)
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 20)
+ }
+
+}
+
+#Preview {
+ Page2RelationEventView(
+ store: Store(initialState: AddDoriFeature.State(mode: .create)) {
+ AddDoriFeature()
+ }
+ )
+}
diff --git a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift
new file mode 100644
index 0000000..5a3039b
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift
@@ -0,0 +1,229 @@
+//
+// Page3AmountDateView.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import SwiftUI
+import ComposableArchitecture
+import DoriDesignSystem
+import DoriCore
+
+struct Page3AmountDateView: View {
+ @Bindable var store: StoreOf
+
+ private let amountPresets: [AmountPreset] = .presets
+ private let options1x2: [DoriSegmentOption] = Visited.allCases.map {
+ $0.toSegmentOptions()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ // 금액
+ amountSection
+
+ // 날짜
+ dateSection
+
+ // 방문 여부
+ visitedSection
+
+ // 메모
+ memoSection
+
+ Spacer()
+
+ // 다음 버튼
+ PrimaryButton(title: "완료") {
+ store.send(.submitTapped)
+ }
+ .isEnable(store.isPage3Valid)
+ }
+ .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
+
+ private var amountSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("도리")
+ .addDoriSectionTitleStyle()
+
+ TextField(
+ "금액을 입력해주세요",
+ text: $store.amountText.sending(\.amountTextChanged)
+ )
+ .keyboardType(.numberPad)
+ .pretendard(.body(.sb3))
+ .roundedStyle()
+
+ HStack(spacing: 8) {
+ ForEach(
+ amountPresets,
+ id: \.self
+ ) { preset in
+ Button {
+ store.send(.addAmountTapped(preset.amount))
+ } label: {
+ Text(preset.title)
+ .pretendard(.body(.r3))
+ .foregroundStyle(DoriColors.grey600.color)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(DoriColors.grey300.color)
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private var dateSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("날짜")
+ .addDoriSectionTitleStyle()
+
+ let dateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy/MM/dd"
+ return f
+ }()
+
+ HStack {
+ Text(dateFormatter.string(from: store.eventDate))
+ .pretendard(.body(.sb3))
+ .foregroundStyle(.doriBlack)
+
+ Spacer()
+
+ Button {
+ store.send(.datePickerToggled)
+ } label: {
+ Image(.iconCalendar)
+ }
+ }
+ .roundedStyle()
+
+ }
+ }
+
+ private var visitedSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("방문 여부")
+ .addDoriSectionTitleStyle()
+
+ DoriSegmentGridWithMemo(
+ options: options1x2,
+ selection: $store.isVisited.sending(\.isVisitedChanged),
+ memo: $store.memo.sending(\.memoChanged)
+ )
+ }
+ }
+
+ private var memoSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("메모(선택)")
+ .addDoriSectionTitleStyle()
+
+ DoriTextField(
+ "메모를 입력해주세요 (40자)",
+ memo: $store.memo.sending(\.memoChanged),
+ maxLength: 40
+ )
+ .lineLimit(3...5)
+ }
+ }
+}
+
+struct AmountPreset: Hashable {
+ let title: String
+ let amount: Int
+
+ init(_ title: String, _ amount: Int) {
+ self.title = title
+ self.amount = amount
+ }
+}
+
+extension [AmountPreset] {
+ static let presets: [AmountPreset] = [
+ .init("+1만", 10_000),
+ .init("+3만", 30_000),
+ .init("+5만", 50_000),
+ .init("+10만", 100_000)
+ ]
+}
+
+#Preview {
+ Page3AmountDateView(
+ store: Store(initialState: AddDoriFeature.State(mode: .create)) {
+ AddDoriFeature()
+ }
+ )
+}
+
+struct AddDoriCalendarView: View {
+ @State private var selection: Date
+
+ private let dismissAction: () -> Void
+ private let selecionAction: (Date) -> Void
+
+ init(
+ initialDate: Date,
+ dismissAction: @escaping () -> Void,
+ selecionAction: @escaping (Date) -> Void
+ ) {
+ self._selection = State(initialValue: initialDate)
+ self.dismissAction = dismissAction
+ self.selecionAction = selecionAction
+ }
+
+ var body: some View {
+ VStack {
+ DatePicker(
+ "",
+ selection: $selection,
+ displayedComponents: .date
+ )
+ .datePickerStyle(.graphical)
+ .tint(DoriColors.main.color)
+ .padding()
+ .background(DoriColors.doriWhite.color)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+
+ HStack {
+ PrimaryButton(title: "나가기") {
+ dismissAction()
+ }
+ .backgroundColor(.grey100)
+ .foregroundColor(.doriBlack)
+
+ PrimaryButton(title: "날짜 선택") {
+ selecionAction(selection)
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+
+ }
+}
diff --git a/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift b/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift
new file mode 100644
index 0000000..d43e344
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift
@@ -0,0 +1,96 @@
+//
+// PartnerSearchResultRow.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/15/26.
+//
+
+import SwiftUI
+import DoriDesignSystem
+import DoriNetwork
+
+struct PartnerSearchResultRow: View {
+ @Binding var searchQuery: String
+ let partner: DoriResponsesDTO
+
+ var body: some View {
+ HStack(spacing: 6) {
+ HStack(spacing: 6) {
+ Text(highlightedName)
+ .pretendard(.body(.r4))
+ .lineLimit(2)
+
+ Text(partner.relationship)
+ .pretendard(.caption(.m2))
+ .foregroundStyle(DoriColors.grey600.color)
+ .fixedSize(horizontal: false, vertical: true)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .fill(DoriColors.grey100.color)
+ )
+ }
+
+ Spacer()
+
+ Text(partner.eventType)
+ .pretendard(.body(.m5))
+ .foregroundStyle(DoriColors.grey500.color)
+
+ Text(partner.eventDate)
+ .pretendard(.body(.m5))
+ .foregroundStyle(DoriColors.grey500.color)
+ }
+ }
+
+ private var highlightedName: AttributedString {
+ var attributed = AttributedString(partner.partnerName)
+
+ guard !searchQuery.isEmpty,
+ let range = attributed.range(of: searchQuery, options: .caseInsensitive)
+ else {
+ return attributed
+ }
+
+ // 기본 폰트를 먼저 전체에 적용
+ attributed[attributed.startIndex.. Font {
+ let provider = PretendardProvider()
+ let style = name.getFontStyle(with: provider)
+ return style.font
+ }
+}
+
+#Preview {
+ @Previewable @State var searchQuery = "박수진"
+ PartnerSearchResultRow(
+ searchQuery: $searchQuery,
+ partner: DoriResponsesDTO(
+ doriId: 1,
+ userId: 1,
+ partnerId: 1,
+ direction: "주도리",
+ partnerName: "박수진수진수진수진수",
+ relationship: "친구야친구야",
+ eventType: "결혼식",
+ amount: 100_000,
+ eventDate: "2024-05-12",
+ isVisited: true,
+ memo: "",
+ createdAt: "2026-05-12"
+ )
+ )
+ .padding(20)
+ .padding(.horizontal, 16)
+}
diff --git a/Projects/Feature/AddDori/Sources/Visited.swift b/Projects/Feature/AddDori/Sources/Visited.swift
new file mode 100644
index 0000000..9b45c41
--- /dev/null
+++ b/Projects/Feature/AddDori/Sources/Visited.swift
@@ -0,0 +1,20 @@
+//
+// Visited.swift
+// AddDori
+//
+// Created by 강동영 on 2/19/26.
+// Copyright © 2026 com.arex. All rights reserved.
+//
+
+
+public enum Visited: String, CaseIterable, Hashable, Sendable {
+ case yes = "예"
+ case no = "아니오"
+
+ var boolValue: Bool {
+ switch self {
+ case .yes: return true
+ case .no: return false
+ }
+ }
+}
diff --git a/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift b/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift
new file mode 100644
index 0000000..2dff6bb
--- /dev/null
+++ b/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift
@@ -0,0 +1,1112 @@
+//
+// AddDoriFeatureTests.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/19/26.
+//
+
+import ComposableArchitecture
+import DoriNetwork
+import Testing
+@testable import AddDori
+
+// MARK: - Mock Data
+
+private extension DoriResponsesDTO {
+ static func mock(
+ doriId: Int64 = 1,
+ userId: Int64 = 1,
+ partnerId: Int64 = 1,
+ direction: String = "주도리",
+ partnerName: String = "김철수",
+ relationship: String = "친구",
+ eventType: String = "결혼식",
+ amount: Int32 = 100_000,
+ eventDate: String = "2026-02-15",
+ isVisited: Bool = true,
+ memo: String = "",
+ createdAt: String = "2026-02-15"
+ ) -> DoriResponsesDTO {
+ DoriResponsesDTO(
+ doriId: doriId,
+ userId: userId,
+ partnerId: partnerId,
+ direction: direction,
+ partnerName: partnerName,
+ relationship: relationship,
+ eventType: eventType,
+ amount: amount,
+ eventDate: eventDate,
+ isVisited: isVisited,
+ memo: memo,
+ createdAt: createdAt
+ )
+ }
+}
+
+// MARK: - Test Suite
+
+@Suite("AddDoriFeature 테스트")
+struct AddDoriFeatureTests {
+
+ // MARK: - 초기 상태
+
+ @Suite("초기 상태 확인")
+ struct InitialStateTests {
+
+ @Test("create 모드 기본값 확인")
+ func createModeInitialState() {
+ let state = AddDoriFeature.State(mode: .create)
+ #expect(state.currentPage == 0)
+ #expect(state.transactionType == .given)
+ #expect(state.searchQuery == "")
+ #expect(state.searchResults == [])
+ #expect(state.selectedPartner == nil)
+ #expect(state.isSearching == false)
+ #expect(state.selectedRelationship == .friend)
+ #expect(state.customRelationship == "")
+ #expect(state.selectedEventType == .wedding)
+ #expect(state.customEventType == "")
+ #expect(state.amountText == "")
+ #expect(state.isVisited == .yes)
+ #expect(state.memo == "")
+ #expect(state.isSubmitting == false)
+ #expect(state.navigationTitle == "내역 추가")
+ }
+
+ @Test("edit 모드 - known relationship/eventType 초기화")
+ func editModeWithKnownTypes() {
+ let dto = DoriResponsesDTO.mock(
+ direction: "받도리",
+ partnerName: "이영희",
+ relationship: "가족",
+ eventType: "장례식",
+ amount: 50_000,
+ isVisited: false,
+ memo: "메모 내용"
+ )
+ let state = AddDoriFeature.State(mode: .edit(dto))
+
+ #expect(state.transactionType == .received)
+ #expect(state.searchQuery == "이영희")
+ #expect(state.selectedRelationship == .family)
+ #expect(state.customRelationship == "")
+ #expect(state.selectedEventType == .funeral)
+ #expect(state.customEventType == "")
+ #expect(state.amountText == "50,000")
+ #expect(state.isVisited == .no)
+ #expect(state.memo == "메모 내용")
+ #expect(state.navigationTitle == "내역 수정")
+ }
+
+ @Test("edit 모드 - unknown relationship 초기화")
+ func editModeWithUnknownRelationship() {
+ let dto = DoriResponsesDTO.mock(relationship: "동네친구")
+ let state = AddDoriFeature.State(mode: .edit(dto))
+
+ #expect(state.selectedRelationship == .other)
+ #expect(state.customRelationship == "동네친구")
+ }
+
+ @Test("edit 모드 - unknown eventType 초기화")
+ func editModeWithUnknownEventType() {
+ let dto = DoriResponsesDTO.mock(eventType: "회갑잔치")
+ let state = AddDoriFeature.State(mode: .edit(dto))
+
+ #expect(state.selectedEventType == .other)
+ #expect(state.customEventType == "회갑잔치")
+ }
+ }
+
+ // MARK: - Validation
+
+ @Suite("유효성 검사")
+ struct ValidationTests {
+
+ @Test("isPage1Valid - searchQuery만 있을 때 유효")
+ func page1ValidWithSearchQuery() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.searchQuery = "홍길동"
+ #expect(state.isPage1Valid == true)
+ }
+
+ @Test("isPage1Valid - selectedPartner만 있을 때 유효")
+ func page1ValidWithSelectedPartner() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedPartner = .mock()
+ #expect(state.isPage1Valid == true)
+ }
+
+ @Test("isPage1Valid - 공백 searchQuery, partner 없으면 무효")
+ func page1InvalidWhenEmpty() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.searchQuery = " "
+ state.selectedPartner = nil
+ #expect(state.isPage1Valid == false)
+ }
+
+ @Test("isPage2Valid - other 관계에 customRelationship 없으면 무효")
+ func page2InvalidWithOtherRelationshipEmpty() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedRelationship = .other
+ state.customRelationship = ""
+ #expect(state.isPage2Valid == false)
+ }
+
+ @Test("isPage2Valid - other 관계에 customRelationship 있으면 유효")
+ func page2ValidWithOtherRelationshipFilled() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedRelationship = .other
+ state.customRelationship = "동창"
+ state.selectedEventType = .wedding
+ #expect(state.isPage2Valid == true)
+ }
+
+ @Test("isPage3Valid - 유효한 금액이면 true")
+ func page3ValidWithAmount() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.amountText = "50000"
+ state.isVisited = .yes
+ #expect(state.isPage3Valid == true)
+ }
+
+ @Test("isPage3Valid - 금액 0이면 무효")
+ func page3InvalidWithZeroAmount() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.amountText = "0"
+ #expect(state.isPage3Valid == false)
+ }
+
+ @Test("isPage3Valid - 금액 비어있으면 무효")
+ func page3InvalidWithEmptyAmount() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.amountText = ""
+ #expect(state.isPage3Valid == false)
+ }
+
+ @Test("isPage3Valid - 포맷된 문자열 '100,000'으로도 유효")
+ func page3ValidWithFormattedAmount() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.amountText = "100,000"
+ state.isVisited = .yes
+ #expect(state.isPage3Valid == true)
+ }
+
+ @Test("isPage3Valid - 공백 문자 포함된 포맷 문자열도 유효")
+ func page3ValidWithLargeFormattedAmount() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.amountText = "1,000,000"
+ state.isVisited = .yes
+ #expect(state.isPage3Valid == true)
+ }
+ }
+
+ // MARK: - Navigation
+
+ @Suite("페이지 네비게이션")
+ struct NavigationTests {
+
+ @Test("nextPageTapped - 0에서 1로 이동")
+ func nextPageFrom0To1() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.nextPageTapped) {
+ $0.currentPage = 1
+ }
+ }
+
+ @Test("nextPageTapped - 1에서 2로 이동")
+ func nextPageFrom1To2() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.currentPage = 1
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.nextPageTapped) {
+ $0.currentPage = 2
+ }
+ }
+
+ @Test("nextPageTapped - 2에서 no-op")
+ func nextPageFrom2IsNoOp() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.currentPage = 2
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.nextPageTapped)
+ }
+
+ @Test("previousPageTapped - 2에서 1로 이동")
+ func previousPageFrom2To1() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.currentPage = 2
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.previousPageTapped) {
+ $0.currentPage = 1
+ }
+ }
+
+ @Test("previousPageTapped - 1에서 0으로 이동")
+ func previousPageFrom1To0() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.currentPage = 1
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.previousPageTapped) {
+ $0.currentPage = 0
+ }
+ }
+
+ @Test("previousPageTapped - 0에서 no-op")
+ func previousPageFrom0IsNoOp() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.previousPageTapped)
+ }
+ }
+
+ // MARK: - Page 1: 이름/구분
+
+ @Suite("Page 1 - 이름/구분")
+ struct Page1Tests {
+
+ @Test("transactionTypeChanged - 받도리로 변경")
+ func transactionTypeChanged() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.transactionTypeChanged(.received)) {
+ $0.transactionType = .received
+ }
+ }
+
+ @Test("searchQueryChanged - 검색어 입력 시 isSearching = true + Effect 실행")
+ func searchQueryChangedTriggersEffect() async {
+ let mockResults = [DoriResponsesDTO.mock(partnerName: "김철수")]
+
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.continuousClock = ImmediateClock()
+ $0.addDoriAPIClient.searchPartners = { _ in mockResults }
+ }
+
+ await store.send(.searchQueryChanged("김철수")) {
+ $0.searchQuery = "김철수"
+ $0.selectedPartner = nil
+ $0.isSearching = true
+ }
+
+ await store.receive(.searchResponse(mockResults)) {
+ $0.searchResults = mockResults
+ $0.isSearching = false
+ }
+ }
+
+ @Test("searchQueryChanged - 10자 초과 시 앞 10자만 저장")
+ func searchQueryChangedTruncatedAt10() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.continuousClock = ImmediateClock()
+ $0.addDoriAPIClient.searchPartners = { _ in [] }
+ }
+
+ let longQuery = "12345678901234"
+ await store.send(.searchQueryChanged(longQuery)) {
+ $0.searchQuery = "1234567890"
+ $0.selectedPartner = nil
+ $0.isSearching = true
+ }
+
+ await store.receive(.searchResponse([])) {
+ $0.searchResults = []
+ $0.isSearching = false
+ }
+ }
+
+ @Test("searchQueryChanged - 빈 문자열 입력 시 results 초기화, isSearching = false")
+ func searchQueryChangedWithEmptyStringClearsResults() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.searchQuery = "김"
+ initial.searchResults = [.mock()]
+ initial.isSearching = true
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.searchQueryChanged("")) {
+ $0.searchQuery = ""
+ $0.selectedPartner = nil
+ $0.searchResults = []
+ $0.isSearching = false
+ }
+ }
+
+ @Test("searchQueryChanged - 공백만 입력 시 results 초기화")
+ func searchQueryChangedWithWhitespaceOnlyClearsResults() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.searchResults = [.mock()]
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.searchQueryChanged(" ")) {
+ $0.searchQuery = " "
+ $0.selectedPartner = nil
+ $0.searchResults = []
+ $0.isSearching = false
+ }
+ }
+
+ @Test("searchResponse - results 업데이트 및 isSearching = false")
+ func searchResponseUpdatesResults() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.isSearching = true
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ let results = [DoriResponsesDTO.mock()]
+ await store.send(.searchResponse(results)) {
+ $0.searchResults = results
+ $0.isSearching = false
+ }
+ }
+
+ @Test("partnerSelected - 파트너 선택 시 searchQuery, relationship 업데이트")
+ func partnerSelectedUpdatesState() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.searchResults = [.mock(partnerName: "박민수", relationship: "회사")]
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ let partner = DoriResponsesDTO.mock(
+ partnerName: "박민수",
+ relationship: "회사"
+ )
+
+ await store.send(.partnerSelected(partner)) {
+ $0.selectedPartner = partner
+ $0.searchQuery = "박민수"
+ $0.searchResults = []
+ $0.selectedRelationship = .company
+ }
+ }
+
+ @Test("partnerSelected - unknown relationship 파트너 선택 시 customRelationship 설정")
+ func partnerSelectedWithUnknownRelationship() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ let partner = DoriResponsesDTO.mock(
+ partnerName: "홍길동",
+ relationship: "지인"
+ )
+
+ await store.send(.partnerSelected(partner)) {
+ $0.selectedPartner = partner
+ $0.searchQuery = "홍길동"
+ $0.searchResults = []
+ $0.selectedRelationship = .other
+ $0.customRelationship = "지인"
+ }
+ }
+
+ @Test("partnerSelected(nil) - 선택 해제")
+ func partnerDeselected() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.selectedPartner = .mock()
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.partnerSelected(nil)) {
+ $0.selectedPartner = nil
+ }
+ }
+
+ @Test("clearSearchTapped - 검색 상태 전체 초기화")
+ func clearSearchTappedResetsSearchState() async {
+ // clearSearchTapped Reducer는 isSearching을 별도로 변경하지 않으므로
+ // initial에서 isSearching은 기본값(false)으로 유지한다
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.searchQuery = "김철수"
+ initial.searchResults = [.mock()]
+ initial.selectedPartner = .mock()
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.clearSearchTapped) {
+ $0.searchQuery = ""
+ $0.searchResults = []
+ $0.selectedPartner = nil
+ }
+ }
+ }
+
+ // MARK: - Page 2: 관계/경조사
+
+ @Suite("Page 2 - 관계/경조사")
+ struct Page2Tests {
+
+ @Test("relationshipSelected - 관계 변경")
+ func relationshipSelected() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.relationshipSelected(.family)) {
+ $0.selectedRelationship = .family
+ }
+ }
+
+ @Test("relationshipSelected - other 아닌 값 선택 시 customRelationship 초기화")
+ func relationshipSelectedClearsCustomWhenNotOther() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.selectedRelationship = .other
+ initial.customRelationship = "동창"
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.relationshipSelected(.friend)) {
+ $0.selectedRelationship = .friend
+ $0.customRelationship = ""
+ }
+ }
+
+ @Test("customRelationshipChanged - 10자 이내 저장")
+ func customRelationshipChangedWithinLimit() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.customRelationshipChanged("동창친구")) {
+ $0.customRelationship = "동창친구"
+ }
+ }
+
+ @Test("customRelationshipChanged - 10자 초과 시 앞 10자만 저장")
+ func customRelationshipChangedTruncatedAt10() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ let longText = "12345678901234"
+ await store.send(.customRelationshipChanged(longText)) {
+ $0.customRelationship = "1234567890"
+ }
+ }
+
+ @Test("eventTypeSelected - 경조사 변경")
+ func eventTypeSelected() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.eventTypeSelected(.funeral)) {
+ $0.selectedEventType = .funeral
+ }
+ }
+
+ @Test("eventTypeSelected - other 아닌 값 선택 시 customEventType 초기화")
+ func eventTypeSelectedClearsCustomWhenNotOther() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.selectedEventType = .other
+ initial.customEventType = "회갑잔치"
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.eventTypeSelected(.birthday)) {
+ $0.selectedEventType = .birthday
+ $0.customEventType = ""
+ }
+ }
+
+ @Test("customEventTypeChanged - 10자 초과 시 앞 10자만 저장")
+ func customEventTypeChangedTruncatedAt10() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ let longText = "가나다라마바사아자차카"
+ await store.send(.customEventTypeChanged(longText)) {
+ $0.customEventType = "가나다라마바사아자차"
+ }
+ }
+ }
+
+ // MARK: - Page 3: 금액/날짜
+
+ @Suite("Page 3 - 금액/날짜")
+ struct Page3Tests {
+
+ @Test("amountTextChanged - 빈 문자열 입력 시 빈 문자열 저장")
+ func amountTextChangedWithEmptyString() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.amountTextChanged("")) {
+ $0.amountText = ""
+ }
+ }
+
+ @Test("amountTextChanged - 문자 포함 입력 시 숫자만 추출 후 포맷")
+ func amountTextChangedFiltersNumbers() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.amountTextChanged("10a0b0c")) {
+ $0.amountText = "1,000"
+ }
+ }
+
+ @Test("amountTextChanged - 100,000 포맷팅 검증")
+ func amountTextChangedFormatsDecimal() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.amountTextChanged("100000")) {
+ $0.amountText = "100,000"
+ }
+ }
+
+ @Test("amountTextChanged - 순수 숫자 입력")
+ func amountTextChangedWithPureNumbers() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.amountTextChanged("50000")) {
+ $0.amountText = "50,000"
+ }
+ }
+
+ @Test("amountTextChanged - 21억 초과 입력 시 Int32.max로 클리핑")
+ func amountTextChangedClipsAtInt32Max() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.amountTextChanged("9999999999")) {
+ $0.amountText = Int(Int32.max).decimalFormatted
+ }
+ }
+
+ @Test("amountTextChanged - Int32.max 정확히 입력 시 그대로 저장")
+ func amountTextChangedWithExactInt32Max() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.amountTextChanged("\(Int32.max)")) {
+ $0.amountText = Int(Int32.max).decimalFormatted
+ }
+ }
+
+ @Test("addAmountTapped - 기존 금액에 추가")
+ func addAmountTappedAddsToExisting() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.amountText = "30,000"
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.addAmountTapped(10_000)) {
+ $0.amountText = "40,000"
+ }
+ }
+
+ @Test("addAmountTapped - 빈 금액에서 추가")
+ func addAmountTappedFromEmpty() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.addAmountTapped(50_000)) {
+ $0.amountText = "50,000"
+ }
+ }
+
+ @Test("addAmountTapped - 21억 초과 시 Int32.max로 클리핑")
+ func addAmountTappedClipsAtInt32Max() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.amountText = Int(Int32.max - 100).decimalFormatted
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.addAmountTapped(1_000_000)) {
+ $0.amountText = Int(Int32.max).decimalFormatted
+ }
+ }
+
+ @Test("eventDateChanged - 날짜 변경")
+ func eventDateChanged() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ let newDate = Date(timeIntervalSince1970: 1_739_923_200) // 2025-02-19
+ await store.send(.eventDateChanged(newDate)) {
+ $0.eventDate = newDate
+ }
+ }
+
+ @Test("isVisitedChanged - 방문 여부 변경")
+ func isVisitedChanged() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.isVisitedChanged(.no)) {
+ $0.isVisited = .no
+ }
+ }
+
+ @Test("memoChanged - 40자 이내 저장")
+ func memoChangedWithinLimit() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.memoChanged("좋은 자리였습니다")) {
+ $0.memo = "좋은 자리였습니다"
+ }
+ }
+
+ @Test("memoChanged - 40자 초과 시 앞 40자만 저장")
+ func memoChangedTruncatedAt40() async {
+ let store = TestStore(
+ initialState: AddDoriFeature.State(mode: .create)
+ ) {
+ AddDoriFeature()
+ }
+
+ let longMemo = String(repeating: "가", count: 50)
+ let expected = String(repeating: "가", count: 40)
+ await store.send(.memoChanged(longMemo)) {
+ $0.memo = expected
+ }
+ }
+ }
+
+ // MARK: - Submit
+
+ @Suite("Submit - 등록/수정")
+ struct SubmitTests {
+
+ @Test("create 모드 - 제출 성공 시 doriCreated delegate 발생")
+ func createSubmitSuccess() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.searchQuery = "김철수"
+ initial.selectedRelationship = .friend
+ initial.selectedEventType = .wedding
+ initial.amountText = "100,000"
+ initial.isVisited = .yes
+
+ let mockResponse = DoriResponsesDTO.mock(
+ partnerName: "김철수",
+ relationship: "친구",
+ eventType: "결혼식",
+ amount: 100_000
+ )
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.addDoriAPIClient.createDori = { _ in mockResponse }
+ }
+
+ await store.send(.submitTapped) {
+ $0.isSubmitting = true
+ }
+
+ await store.receive(.submitResponse(.success(mockResponse))) {
+ $0.isSubmitting = false
+ }
+
+ await store.receive(.delegate(.doriCreated(mockResponse)))
+ }
+
+ @Test("create 모드 - 제출 실패 시 isSubmitting = false")
+ func createSubmitFailure() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.searchQuery = "김철수"
+ initial.amountText = "100,000"
+
+ struct TestError: Error {
+ let message = "서버 오류"
+ var localizedDescription: String { message }
+ }
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.addDoriAPIClient.createDori = { _ in throw TestError() }
+ }
+
+ await store.send(.submitTapped) {
+ $0.isSubmitting = true
+ }
+
+ await store.receive(
+ .submitResponse(
+ .failure(
+ AddDoriFeature.SubmitError(message: "서버 오류")
+ )
+ )
+ ) {
+ $0.isSubmitting = false
+ }
+ }
+
+ @Test("create 모드 - request에 올바른 값이 전달되는지 검증")
+ func createSubmitRequestValues() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.transactionType = .given
+ initial.searchQuery = "박수진"
+ initial.selectedRelationship = .friend
+ initial.selectedEventType = .wedding
+ initial.amountText = "100,000"
+ initial.isVisited = .yes
+ initial.memo = "테스트 메모"
+
+ let mockResponse = DoriResponsesDTO.mock(partnerName: "박수진")
+
+ nonisolated(unsafe) var capturedRequest: DoriPostRequest?
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.addDoriAPIClient.createDori = { request in
+ capturedRequest = request
+ return mockResponse
+ }
+ }
+
+ await store.send(.submitTapped) {
+ $0.isSubmitting = true
+ }
+
+ await store.receive(.submitResponse(.success(mockResponse))) {
+ $0.isSubmitting = false
+ }
+
+ await store.receive(.delegate(.doriCreated(mockResponse)))
+
+ #expect(capturedRequest?.direction == "주도리")
+ #expect(capturedRequest?.partnerName == "박수진")
+ #expect(capturedRequest?.relationship == "친구")
+ #expect(capturedRequest?.eventType == "결혼식")
+ #expect(capturedRequest?.amount == 100_000)
+ #expect(capturedRequest?.isVisited == true)
+ #expect(capturedRequest?.memo == "테스트 메모")
+ }
+
+ @Test("edit 모드 - 제출 성공 시 doriUpdated delegate 발생")
+ func editSubmitSuccess() async {
+ let existingDori = DoriResponsesDTO.mock(
+ doriId: 42,
+ partnerName: "이영희",
+ relationship: "가족",
+ eventType: "생일",
+ amount: 50_000
+ )
+
+ var initial = AddDoriFeature.State(mode: .edit(existingDori))
+ initial.amountText = "80,000"
+
+ let updatedResponse = DoriResponsesDTO.mock(
+ doriId: 42,
+ partnerName: "이영희",
+ relationship: "가족",
+ eventType: "생일",
+ amount: 80_000
+ )
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.addDoriAPIClient.updateDori = { _, _ in updatedResponse }
+ }
+
+ await store.send(.submitTapped) {
+ $0.isSubmitting = true
+ }
+
+ await store.receive(.submitResponse(.success(updatedResponse))) {
+ $0.isSubmitting = false
+ }
+
+ await store.receive(.delegate(.doriUpdated(updatedResponse)))
+ }
+
+ @Test("edit 모드 - 제출 실패 시 isSubmitting = false")
+ func editSubmitFailure() async {
+ let existingDori = DoriResponsesDTO.mock()
+
+ var initial = AddDoriFeature.State(mode: .edit(existingDori))
+ initial.amountText = "80,000"
+
+ struct TestError: Error {
+ let message = "네트워크 오류"
+ var localizedDescription: String { message }
+ }
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.addDoriAPIClient.updateDori = { _, _ in throw TestError() }
+ }
+
+ await store.send(.submitTapped) {
+ $0.isSubmitting = true
+ }
+
+ await store.receive(
+ .submitResponse(
+ .failure(
+ AddDoriFeature.SubmitError(message: "네트워크 오류")
+ )
+ )
+ ) {
+ $0.isSubmitting = false
+ }
+ }
+
+ @Test("edit 모드 - request에 올바른 값이 전달되는지 검증")
+ func editSubmitRequestValues() async {
+ let existingDori = DoriResponsesDTO.mock(
+ doriId: 42,
+ direction: "받도리",
+ partnerName: "이영희",
+ relationship: "가족",
+ eventType: "장례식",
+ amount: 50_000
+ )
+
+ var initial = AddDoriFeature.State(mode: .edit(existingDori))
+ initial.amountText = "80,000"
+ initial.selectedEventType = .funeral
+
+ let updatedResponse = DoriResponsesDTO.mock(
+ doriId: 42,
+ direction: "받도리",
+ eventType: "장례식",
+ amount: 80_000
+ )
+
+ nonisolated(unsafe) var capturedDoriId: Int64?
+ nonisolated(unsafe) var capturedRequest: DoriUpdateRequest?
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ } withDependencies: {
+ $0.addDoriAPIClient.updateDori = { doriId, request in
+ capturedDoriId = doriId
+ capturedRequest = request
+ return updatedResponse
+ }
+ }
+
+ await store.send(.submitTapped) {
+ $0.isSubmitting = true
+ }
+
+ await store.receive(.submitResponse(.success(updatedResponse))) {
+ $0.isSubmitting = false
+ }
+
+ await store.receive(.delegate(.doriUpdated(updatedResponse)))
+
+ #expect(capturedDoriId == 42)
+ #expect(capturedRequest?.amount == 80_000)
+ #expect(capturedRequest?.eventType == "장례식")
+ #expect(capturedRequest?.direction == "받도리")
+ }
+
+ @Test("isSubmitting = true 상태에서 submitTapped - no-op")
+ func submitTappedWhileSubmittingIsNoOp() async {
+ var initial = AddDoriFeature.State(mode: .create)
+ initial.isSubmitting = true
+
+ let store = TestStore(
+ initialState: initial
+ ) {
+ AddDoriFeature()
+ }
+
+ await store.send(.submitTapped)
+ }
+ }
+
+ // MARK: - Computed Properties
+
+ @Suite("Computed Properties")
+ struct ComputedPropertyTests {
+
+ @Test("partnerName - selectedPartner가 있으면 partnerName 반환")
+ func partnerNameFromSelectedPartner() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedPartner = .mock(partnerName: "박철수")
+ state.searchQuery = "박"
+ #expect(state.partnerName == "박철수")
+ }
+
+ @Test("partnerName - selectedPartner 없으면 searchQuery trim 반환")
+ func partnerNameFromSearchQuery() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedPartner = nil
+ state.searchQuery = " 홍길동 "
+ #expect(state.partnerName == "홍길동")
+ }
+
+ @Test("resolvedRelationship - other 선택 시 customRelationship 반환")
+ func resolvedRelationshipWithOther() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedRelationship = .other
+ state.customRelationship = "지인"
+ #expect(state.resolvedRelationship == "지인")
+ }
+
+ @Test("resolvedRelationship - known 선택 시 rawValue 반환")
+ func resolvedRelationshipWithKnown() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedRelationship = .friend
+ #expect(state.resolvedRelationship == "친구")
+ }
+
+ @Test("resolvedEventType - other 선택 시 customEventType 반환")
+ func resolvedEventTypeWithOther() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedEventType = .other
+ state.customEventType = "회갑잔치"
+ #expect(state.resolvedEventType == "회갑잔치")
+ }
+
+ @Test("resolvedEventType - known 선택 시 rawValue 반환")
+ func resolvedEventTypeWithKnown() {
+ var state = AddDoriFeature.State(mode: .create)
+ state.selectedEventType = .wedding
+ #expect(state.resolvedEventType == "결혼식")
+ }
+ }
+}
diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift
index 07fc9c3..fa7b6ff 100644
--- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift
+++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift
@@ -7,6 +7,8 @@
import ComposableArchitecture
import SwiftUI
+import DoriDesignSystem
+import FeatureAddDori
@Reducer
public struct CalendarFeature {
@@ -14,30 +16,75 @@ public struct CalendarFeature {
@ObservableState
public struct State: Equatable, Sendable {
+ @Presents public var addDori: AddDoriFeature.State?
+
public init() {}
}
public enum Action: Equatable, Sendable {
case onAppear
+ case fabTapped
+ case addDori(PresentationAction)
}
- public func reduce(into state: inout State, action: Action) -> Effect {
- switch action {
- case .onAppear:
- return .none
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case .fabTapped:
+ state.addDori = AddDoriFeature.State(mode: .create)
+ return .none
+
+ case .addDori(.presented(.delegate(.doriCreated))):
+ state.addDori = nil
+ return .none
+
+ case .addDori(.presented(.delegate(.dismissed))):
+ state.addDori = nil
+ return .none
+
+ case .addDori:
+ return .none
+ }
+ }
+ .ifLet(\.$addDori, action: \.addDori) {
+ AddDoriFeature()
}
}
}
public struct CalendarView: View {
- let store: StoreOf
+ @Bindable var store: StoreOf
public init(store: StoreOf) {
self.store = store
}
public var body: some View {
- Text("캘린더")
+ NavigationStack {
+ ZStack(alignment: .bottomTrailing) {
+ Text("캘린더")
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+
+ FloatingActionButton {
+ store.send(.fabTapped)
+ }
+ .padding(20)
+ }
.onAppear { store.send(.onAppear) }
+ .navigationDestination(
+ item: $store.scope(
+ state: \.addDori,
+ action: \.addDori
+ )
+ ) { addDoriStore in
+ AddDoriView(store: addDoriStore)
+ }
+ }
}
}
diff --git a/Projects/Feature/History/Sources/HistoryFeature.swift b/Projects/Feature/History/Sources/HistoryFeature.swift
index d0981bf..a0539ec 100644
--- a/Projects/Feature/History/Sources/HistoryFeature.swift
+++ b/Projects/Feature/History/Sources/HistoryFeature.swift
@@ -7,6 +7,8 @@
import ComposableArchitecture
import SwiftUI
+import DoriDesignSystem
+import FeatureAddDori
@Reducer
public struct HistoryFeature {
@@ -14,30 +16,75 @@ public struct HistoryFeature {
@ObservableState
public struct State: Equatable, Sendable {
+ @Presents public var addDori: AddDoriFeature.State?
+
public init() {}
}
public enum Action: Equatable, Sendable {
case onAppear
+ case fabTapped
+ case addDori(PresentationAction)
}
- public func reduce(into state: inout State, action: Action) -> Effect {
- switch action {
- case .onAppear:
- return .none
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case .fabTapped:
+ state.addDori = AddDoriFeature.State(mode: .create)
+ return .none
+
+ case .addDori(.presented(.delegate(.doriCreated))):
+ state.addDori = nil
+ return .none
+
+ case .addDori(.presented(.delegate(.dismissed))):
+ state.addDori = nil
+ return .none
+
+ case .addDori:
+ return .none
+ }
+ }
+ .ifLet(\.$addDori, action: \.addDori) {
+ AddDoriFeature()
}
}
}
public struct HistoryView: View {
- let store: StoreOf
+ @Bindable var store: StoreOf
public init(store: StoreOf) {
self.store = store
}
public var body: some View {
- Text("내역")
+ NavigationStack {
+ ZStack(alignment: .bottomTrailing) {
+ Text("내역")
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+
+ FloatingActionButton {
+ store.send(.fabTapped)
+ }
+ .padding(20)
+ }
.onAppear { store.send(.onAppear) }
+ .navigationDestination(
+ item: $store.scope(
+ state: \.addDori,
+ action: \.addDori
+ )
+ ) { addDoriStore in
+ AddDoriView(store: addDoriStore)
+ }
+ }
}
}
diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift
index a494dd2..dffadba 100644
--- a/Projects/Feature/Project.swift
+++ b/Projects/Feature/Project.swift
@@ -21,13 +21,32 @@ let project = Project.dori(
.external(.composableArchitecture)
]
),
+ .doriFramework(
+ DoriModules.addDori.module,
+ dependencies: [
+ DoriModules.designSystem.module.projectDependency,
+ DoriModules.core.module.projectDependency,
+ DoriModules.network.module.projectDependency,
+ .external(.composableArchitecture)
+ ]
+ ),
.doriFramework(
DoriModules.calendar.module,
- dependencies: [.external(.composableArchitecture)]
+ dependencies: [
+ DoriModules.addDori.module.targetDependency,
+ DoriModules.designSystem.module.projectDependency,
+ DoriModules.core.module.projectDependency,
+ .external(.composableArchitecture)
+ ]
),
.doriFramework(
DoriModules.history.module,
- dependencies: [.external(.composableArchitecture)]
+ dependencies: [
+ DoriModules.addDori.module.targetDependency,
+ DoriModules.designSystem.module.projectDependency,
+ DoriModules.core.module.projectDependency,
+ .external(.composableArchitecture)
+ ]
),
.doriFramework(
DoriModules.myPage.module,
@@ -37,5 +56,20 @@ let project = Project.dori(
.external(.composableArchitecture)
]
),
+// .app(
+// name: "MyPageDemoApp",
+// bundleId: "com.arex.dori.mypage.demo",
+// infoPlist: .extendingDefault(with: [
+// "CFBundleDisplayName": "Home Demo",
+// "UILaunchStoryboardName": "LaunchScreen",
+// "UISupportedInterfaceOrientations": .array([
+// .string("UIInterfaceOrientationPortrait")
+// ])
+// ]),
+// sources: ["Demo/Sources/**"],
+// resources: ["Demo/Resources/**"],
+// dependencies: [],
+// settings: .demoAppSettings,
+// ),
]
)
diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift
new file mode 100644
index 0000000..6cf2d63
--- /dev/null
+++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift
@@ -0,0 +1,51 @@
+//
+// DoriEndpoints.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/19/26.
+//
+
+import Foundation
+
+public struct SearchPartnersEndpoint: Endpoint {
+ public let baseURL: String
+ public let path: String = "/dori/search/partners"
+ public let method: HTTPMethod = .GET
+ public let headers: [String: String] = [:]
+ public let queryParameters: [String: String]
+ public let body: Data? = nil
+
+ public init(query: String, baseURL: String = NetworkConfig.baseURL) {
+ self.baseURL = baseURL
+ self.queryParameters = ["query": query]
+ }
+}
+
+public struct CreateDoriEndpoint: Endpoint {
+ public let baseURL: String
+ public let path: String = "/dori"
+ public let method: HTTPMethod = .POST
+ public let headers: [String: String] = [:]
+ public let queryParameters: [String: String] = [:]
+ public let body: Data?
+
+ public init(request: DoriPostRequest, baseURL: String = NetworkConfig.baseURL) {
+ self.baseURL = baseURL
+ self.body = try? JSONEncoder().encode(request)
+ }
+}
+
+public struct UpdateDoriEndpoint: Endpoint {
+ public let baseURL: String
+ public let path: String = "/dori"
+ public let method: HTTPMethod = .PUT
+ public let headers: [String: String] = [:]
+ public let queryParameters: [String: String]
+ public let body: Data?
+
+ public init(doriId: Int64, request: DoriUpdateRequest, baseURL: String = NetworkConfig.baseURL) {
+ self.baseURL = baseURL
+ self.queryParameters = ["doriId": String(doriId)]
+ self.body = try? JSONEncoder().encode(request)
+ }
+}
diff --git a/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift b/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift
new file mode 100644
index 0000000..913ebbf
--- /dev/null
+++ b/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift
@@ -0,0 +1,68 @@
+//
+// DoriRequests.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/10/26.
+//
+
+import Foundation
+
+// MARK: - Dori Request DTOs
+public struct DoriPostRequest: Codable, Equatable, Sendable {
+ public let partnerId: Int64
+ public let direction: String
+ public let partnerName: String
+ public let relationship: String
+ public let eventType: String
+ public let amount: Int32
+ public let eventDate: String
+ public let isVisited: Bool
+ public let memo: String?
+
+ public init(
+ partnerId: Int64,
+ direction: String,
+ partnerName: String,
+ relationship: String,
+ eventType: String,
+ amount: Int32,
+ eventDate: String,
+ isVisited: Bool,
+ memo: String? = nil
+ ) {
+ self.partnerId = partnerId
+ self.direction = direction
+ self.partnerName = partnerName
+ self.relationship = relationship
+ self.eventType = eventType
+ self.amount = amount
+ self.eventDate = eventDate
+ self.isVisited = isVisited
+ self.memo = memo
+ }
+}
+
+public struct DoriUpdateRequest: Codable, Equatable, Sendable {
+ public let direction: String?
+ public let eventType: String?
+ public let amount: Int32?
+ public let eventDate: String?
+ public let isVisited: Bool?
+ public let memo: String?
+
+ public init(
+ direction: String? = nil,
+ eventType: String? = nil,
+ amount: Int32? = nil,
+ eventDate: String? = nil,
+ isVisited: Bool? = nil,
+ memo: String? = nil
+ ) {
+ self.direction = direction
+ self.eventType = eventType
+ self.amount = amount
+ self.eventDate = eventDate
+ self.isVisited = isVisited
+ self.memo = memo
+ }
+}
diff --git a/Projects/Infra/DoriNetwork/Sources/Requests/PartnerRequests.swift b/Projects/Infra/DoriNetwork/Sources/Requests/PartnerRequests.swift
new file mode 100644
index 0000000..c172c36
--- /dev/null
+++ b/Projects/Infra/DoriNetwork/Sources/Requests/PartnerRequests.swift
@@ -0,0 +1,51 @@
+//
+// PartnerRequests.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/10/26.
+//
+
+import Foundation
+
+// MARK: - Partner Request DTOs
+
+public struct PartnerCreateRequest: Codable, Equatable, Sendable {
+ public let name: String
+ public let relationship: String
+ public let nickname: String?
+ public let ageGroup: String?
+ public let gender: String?
+
+ public init(
+ name: String,
+ relationship: String,
+ nickname: String? = nil,
+ ageGroup: String? = nil,
+ gender: String? = nil
+ ) {
+ self.name = name
+ self.relationship = relationship
+ self.nickname = nickname
+ self.ageGroup = ageGroup
+ self.gender = gender
+ }
+}
+
+public struct PartnerUpdateRequest: Codable, Equatable, Sendable {
+ public let fromPartnerName: String
+ public let fromRelationship: String
+ public let toPartnerName: String
+ public let toRelationship: String
+
+ public init(
+ fromPartnerName: String,
+ fromRelationship: String,
+ toPartnerName: String,
+ toRelationship: String
+ ) {
+ self.fromPartnerName = fromPartnerName
+ self.fromRelationship = fromRelationship
+ self.toPartnerName = toPartnerName
+ self.toRelationship = toRelationship
+ }
+}
diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift
new file mode 100644
index 0000000..cbe89e4
--- /dev/null
+++ b/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift
@@ -0,0 +1,55 @@
+//
+// DoriResponses.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/10/26.
+//
+
+import Foundation
+
+// MARK: - Dori Response DTOs
+
+public struct DoriResponsesDTO: Codable, Equatable, Sendable {
+ public let doriId: Int64
+ public let userId: Int64
+ public let partnerId: Int64
+ public let direction: String
+ public let partnerName: String
+ public let relationship: String
+ public let eventType: String
+ public let amount: Int32
+ public let eventDate: String
+ public let isVisited: Bool
+ public let memo: String
+ public let createdAt: String
+
+ public init(
+ doriId: Int64,
+ userId: Int64,
+ partnerId: Int64,
+ direction: String,
+ partnerName: String,
+ relationship: String,
+ eventType: String,
+ amount: Int32,
+ eventDate: String,
+ isVisited: Bool,
+ memo: String,
+ createdAt: String
+ ) {
+ self.doriId = doriId
+ self.userId = userId
+ self.partnerId = partnerId
+ self.direction = direction
+ self.partnerName = partnerName
+ self.relationship = relationship
+ self.eventType = eventType
+ self.amount = amount
+ self.eventDate = eventDate
+ self.isVisited = isVisited
+ self.memo = memo
+ self.createdAt = createdAt
+ }
+}
+
+
diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift
new file mode 100644
index 0000000..0b1d0e6
--- /dev/null
+++ b/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift
@@ -0,0 +1,57 @@
+//
+// PartnerResponses.swift
+// Dori-iOS
+//
+// Created by 강동영 on 2/10/26.
+//
+
+import Foundation
+
+// MARK: - Partner Response DTOs
+
+public struct PartnerDetailResponse: Codable, Equatable, Sendable {
+ public let partnerId: Int64
+ public let userId: Int64
+ public let name: String
+ public let relationship: String
+ public let nickname: String?
+ public let ageGroup: String?
+ public let gender: String?
+ public let createdAt: String
+
+ public init(
+ partnerId: Int64,
+ userId: Int64,
+ name: String,
+ relationship: String,
+ nickname: String? = nil,
+ ageGroup: String? = nil,
+ gender: String? = nil,
+ createdAt: String
+ ) {
+ self.partnerId = partnerId
+ self.userId = userId
+ self.name = name
+ self.relationship = relationship
+ self.nickname = nickname
+ self.ageGroup = ageGroup
+ self.gender = gender
+ self.createdAt = createdAt
+ }
+}
+
+public struct PartnerUpdateResponse: Codable, Equatable, Sendable {
+ public let updatedCount: Int32
+
+ public init(updatedCount: Int32) {
+ self.updatedCount = updatedCount
+ }
+}
+
+public struct PartnerExistsResponse: Codable, Equatable, Sendable {
+ public let exists: Bool
+
+ public init(exists: Bool) {
+ self.exists = exists
+ }
+}
diff --git a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift
index fb76973..2a158f3 100644
--- a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift
+++ b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift
@@ -67,6 +67,7 @@ public enum DoriModules: CaseIterable, Sendable {
case calendar
case history
case myPage
+ case addDori
public var module: DoriModule {
switch self {
@@ -90,6 +91,8 @@ public enum DoriModules: CaseIterable, Sendable {
DoriModule(name: "FeatureHistory", layer: .feature, directoryName: "History")
case .myPage:
DoriModule(name: "FeatureMyPage", layer: .feature, directoryName: "MyPage")
+ case .addDori:
+ DoriModule(name: "FeatureAddDori", layer: .feature, directoryName: "AddDori")
}
}
}
diff --git a/Tuist/ResourceSynthesizers/Assets.stencil b/Tuist/ResourceSynthesizers/Assets.stencil
index 11e10e7..dc8a47d 100644
--- a/Tuist/ResourceSynthesizers/Assets.stencil
+++ b/Tuist/ResourceSynthesizers/Assets.stencil
@@ -36,10 +36,24 @@ public extension View {
}
}
+public extension Shape {
+ func stroke(_ colors: UIAsset.Colors, lineWidth: CGFloat) -> some View {
+ self.stroke(colors.color, lineWidth: lineWidth)
+ }
+
+ func fill(_ colors: UIAsset.Colors) -> some View {
+ self.fill(colors.color)
+ }
+}
+
public extension Image {
init(_ images: {{enumName}}.Images) {
self = images.image
}
+
+ init(_ images: {{enumName}}.Icons) {
+ self = images.image
+ }
}
{% macro enumBlock assets %}