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 %}