diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f5a0348..854d644 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,6 +2,21 @@ --- +## Role +이 에이전트는 입력되는 프롬프트에 대해서 다음 2가지 역할을 수행하며 협업한다: + +1. 기술 아키텍처 팀 동료 +2. Devil’s Advocate (비판적 사고 담당) + +### 응답형식 +응답은 항상 다음 구조로 제공한다: + +[아키텍처 관점] +- ... + +[Devil’s Advocate] +- ... + ## 1. 프로젝트 개요 **앱 이름**: Dori(도리) @@ -38,48 +53,8 @@ --- ## 3. 아키텍처 +See @rules/architecture.md -### 3.1 TCA 단방향 데이터 플로우 - -기존 `temp/` MVVM 프로토타입 코드를 TCA로 전환한다. - -- 각 화면 = 1개 `@Reducer` + 1개 SwiftUI `View` -- 상태 변경은 반드시 Action → Reducer → State 경로를 따른다 -- Side Effect는 `Effect`으로 관리한다 - -### 3.2 Navigation 전략 - -| 범위 | 방식 | 설명 | -|------|------|------| -| App Root | Tree-based | `enum State`로 Intro / MainTab 분기 | -| Tab 내부 (History) | Stack-based | `StackState` / `StackAction`로 list → detail push | -| 모달/시트 | Tree-based | `@Presents` / `ifLet`으로 모달 표시 | - -### 3.3 의존성 관리 - -- TCA `@Dependency` 시스템 사용 -- Protocol 대신 **struct + closure** 패턴 (`DependencyKey` 준수) -- `liveValue` / `previewValue` / `testValue` 3단 분리 - -### 3.4 코드 패턴 참조 - -**자동화 스킬** (boilerplate 생성용): - -| Skill | 용도 | -|-------|------| -| `/tca-reducer` | @Reducer 구조 생성 | -| `/tca-view` | TCA Store 연결 SwiftUI View | -| `/tca-client` | @Dependency Client 구조 | - -**레퍼런스 문서** (분기/도메인 지식 필요): - -| 문서 | 용도 | -|------|------| -| `docs/tca-navigation.md` | Tree/Stack/Tab/AppRoot Navigation 패턴 | -| `docs/tca-test.md` | TestStore 기반 테스트 패턴 | -| `docs/tca-network.md` | API Client + 에러 핸들링 | - ---- ## 4. Feature 명세 @@ -153,134 +128,21 @@ --- ## 5. 네트워크 레이어 - -### 5.1 설계 원칙 - -- API Client를 TCA `@Dependency`로 정의 (인터페이스 우선 설계) -- 서버 API 스펙 전달 전까지 **Mock 구현**으로 개발 진행 -- `liveValue` / `previewValue` / `testValue` 3단 분리 - -### 5.2 API Client 구조 - -```swift -@DependencyClient -struct DataClient: Sendable { - var fetchPersons: @Sendable () async throws -> [Person] - var fetchPerson: @Sendable (UUID) async throws -> Person - var fetchTransactions: @Sendable (Date, TransactionType?) async throws -> [Transaction] - var deletePerson: @Sendable (UUID) async throws -> Void - // ... CRUD 메서드 추가 -} -``` - -### 5.3 에러 핸들링 - -```swift -enum APIError: Error, Equatable, Sendable { - case networkError(String) - case decodingError - case unauthorized - case serverError(Int) - case unknown -} -``` - -### 5.4 코드 패턴 - -`/tca-network` skill 참조 - +See @network-layer.md --- ## 6. Swift 6 Concurrency 가이드 - +See @swift-language-guide.md ### 6.1 프로젝트 설정 - `MainActor` 기본 격리 (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`) - Approachable Concurrency ON (`SWIFT_APPROACHABLE_CONCURRENCY = YES`) -### 6.2 규칙 - -| 항목 | 규칙 | -|------|------| -| 도메인 모델 | `Sendable` + `Equatable` 준수 | -| Dependency Client struct | `Sendable` 준수 | -| Client closure 프로퍼티 | `@Sendable` 어노테이션 | -| `@unchecked Sendable` | **사용 금지** | -| TCA Effect 내 async 작업 | `.run { send in }` 패턴 사용 | - -### 6.3 도메인 모델 예시 - -```swift -struct Person: Identifiable, Codable, Equatable, Sendable { - let id: UUID - var name: String - var relationship: Relationship - var transactions: [Transaction] -} -``` - -### 6.4 Effect 패턴 - -```swift -case .loginButtonTapped: - state.isLoading = true - return .run { send in - let result = await Result { - try await kakaoAuthClient.login() - } - await send(.loginResponse(result)) - } -``` - --- ## 7. 디렉토리 구조 -``` -Dori-iOS/ - DoriApp.swift - Core/ - Models/ → Person, Transaction, Relationship, TransactionType - Clients/ → DataClient, KeychainClient, KakaoAuthClient - DataClient.swift - DataClient+Live.swift - DataClient+Mock.swift - KeychainClient.swift - KeychainClient+Live.swift - KakaoAuthClient.swift - KakaoAuthClient+Live.swift - DesignSystem/ - Components/ → AmountLabel, PersonCardView, DoriBarGraphView, - TransactionRowView, DateHeaderView, - DoriSegmentControl, MonthSelectorView, - CalendarGridView, PrimaryButton, - DoriCommonAlert, AlertButton, - FloatingActionButton - Extensions/ → Color+Extensions, Date+Extensions, Int+Extensions - Feature/ - App/ → AppFeature (Root reducer), AppView - AppFeature.swift - AppView.swift - Intro/ → IntroFeature, IntroView - IntroFeature.swift - IntroView.swift - MainTab/ → MainTabFeature, MainTabView - MainTabFeature.swift - MainTabView.swift - Calendar/ → CalendarFeature, CalendarView - CalendarFeature.swift - CalendarView.swift - History/ - HistoryList/ → HistoryListFeature, HistoryListView - HistoryListFeature.swift - HistoryListView.swift - HistoryDetail/ → HistoryDetailFeature, HistoryDetailView - HistoryDetailFeature.swift - HistoryDetailView.swift - MyPage/ → MyPageFeature, MyPageView - MyPageFeature.swift - MyPageView.swift -``` +모듈화 구조에 따라감 ### 의존 방향 @@ -334,44 +196,46 @@ struct CalendarFeature { - UI 표시 문자열: **한국어** (`"로그아웃"`, `"주도리"`, `"받도리"`) - 코드 식별자: **영어** (`loginButtonTapped`, `fetchPersons`) +### 8.4 파일 헤더 템플릿 + +- 신규 생성 파일(`.swift`, `.xcconfig`)에는 아래 헤더 템플릿을 파일 최상단에 추가한다. +- 작성자명은 AI 사용 여부와 무관하게 반드시 `강동영`으로 유지한다. + +```swift +// +// {FileName} +// Dori-iOS +// +// Created by 강동영 on {M/d/yy}. +// +``` + --- ## 9. Git 전략 ### 9.1 브랜치 구조 -| 브랜치 | 용도 | -|--------|------| -| `master` | 릴리즈 | -| `develop` | 통합/CI | -| `feature/{issue}-{desc}` | 기능 개발 | + +- `main`: 릴리즈 +- `fix`: feature, release의 수정사항 +- `develop`: 통합/CI +- `feature/{issue}-{desc}`: 기능 개발 ### 9.2 PR 흐름 ``` -feature/* → develop → master +feature/* → develop → main ``` ### 9.3 민감 설정 관리 - -- Kakao App Key 등 민감 값: `.xcconfig` 파일로 관리 -- `.xcconfig` 파일은 `.gitignore`에 등록하여 저장소에 포함하지 않는다 +See @rules/frontend/security.md --- ## 10. 테스트 전략 +See @rules/frontend/test-strategy.md -### 10.1 Reducer 테스트 - -- TCA `TestStore`를 사용하여 Reducer 로직을 검증한다 -- `withDependencies`로 Mock 의존성을 주입한다 -- `store.send()` → State 변경 검증 -- `store.receive()` → Effect 결과 검증 -- 코드 패턴: `/tca-test` skill 참조 - -### 10.2 유틸리티 테스트 - -- `Date+Extensions`, `Int+Extensions` 등 Extension 유틸 함수 단위 테스트 ### 10.3 MVP 제외 항목 diff --git a/.claude/rules/backend/network-layer.md b/.claude/rules/backend/network-layer.md new file mode 100644 index 0000000..d789aca --- /dev/null +++ b/.claude/rules/backend/network-layer.md @@ -0,0 +1,34 @@ +### 5.1 설계 원칙 + +- API Client를 TCA `@Dependency`로 정의 (인터페이스 우선 설계) +- 서버 API 스펙 전달 전까지 **Mock 구현**으로 개발 진행 +- `liveValue` / `previewValue` / `testValue` 3단 분리 + +### 5.2 API Client 구조 + +```swift +@DependencyClient +struct DataClient: Sendable { + var fetchPersons: @Sendable () async throws -> [Person] + var fetchPerson: @Sendable (UUID) async throws -> Person + var fetchTransactions: @Sendable (Date, TransactionType?) async throws -> [Transaction] + var deletePerson: @Sendable (UUID) async throws -> Void + // ... CRUD 메서드 추가 +} +``` + +### 5.3 에러 핸들링 + +```swift +enum APIError: Error, Equatable, Sendable { + case networkError(String) + case decodingError + case unauthorized + case serverError(Int) + case unknown +} +``` + +### 5.4 코드 패턴 + +`/tca-network` skill 참조 \ No newline at end of file diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md new file mode 100644 index 0000000..de56303 --- /dev/null +++ b/.claude/rules/coding-style.md @@ -0,0 +1,15 @@ +## Project file indentation +Use 2-space indentation + +### +호출하는 매개변수가 2개이상인 경우 개행 +```swift + Person(name: "Name") + Person(name: "Name", age: 15) // AS-IS: 2개 이상인 경우 + + // TO-BE: 2개 이상인 경우 + Person( + name: "Name", + age: 15 + ) +``` \ No newline at end of file diff --git a/.claude/rules/frontend/architecture.md b/.claude/rules/frontend/architecture.md new file mode 100644 index 0000000..0947ad0 --- /dev/null +++ b/.claude/rules/frontend/architecture.md @@ -0,0 +1,37 @@ +### 3.1 TCA 단방향 데이터 플로우 + +- 각 화면 = 1개 `@Reducer` + 1개 SwiftUI `View` +- 상태 변경은 반드시 Action → Reducer → State 경로를 따른다 +- Side Effect는 `Effect`으로 관리한다 + +### 3.2 Navigation 전략 + +| 범위 | 방식 | 설명 | +|------|------|------| +| App Root | Tree-based | `enum State`로 Intro / MainTab 분기 | +| Tab 내부 (History) | Stack-based | `StackState` / `StackAction`로 list → detail push | +| 모달/시트 | Tree-based | `@Presents` / `ifLet`으로 모달 표시 | + +### 3.3 의존성 관리 + +- TCA `@Dependency` 시스템 사용 +- Protocol 대신 **struct + closure** 패턴 (`DependencyKey` 준수) +- `liveValue` / `previewValue` / `testValue` 3단 분리 + +### 3.4 코드 패턴 참조 + +**자동화 스킬** (boilerplate 생성용): + +| Skill | 용도 | +|-------|------| +| `/tca-reducer` | @Reducer 구조 생성 | +| `/tca-view` | TCA Store 연결 SwiftUI View | +| `/tca-client` | @Dependency Client 구조 | + +**레퍼런스 문서** (분기/도메인 지식 필요): + +| 문서 | 용도 | +|------|------| +| `docs/tca-navigation.md` | Tree/Stack/Tab/AppRoot Navigation 패턴 | +| `docs/tca-test.md` | TestStore 기반 테스트 패턴 | +| `docs/tca-network.md` | API Client + 에러 핸들링 | diff --git a/.claude/rules/frontend/security.md b/.claude/rules/frontend/security.md new file mode 100644 index 0000000..4b4fa60 --- /dev/null +++ b/.claude/rules/frontend/security.md @@ -0,0 +1,2 @@ +- Kakao App Key 등 민감 값: `.xcconfig` 파일로 관리 +- `.xcconfig` 파일은 `.gitignore`에 등록하여 저장소에 포함하지 않는다 \ No newline at end of file diff --git a/.claude/rules/frontend/swift-language-guide.md b/.claude/rules/frontend/swift-language-guide.md new file mode 100644 index 0000000..0a82b33 --- /dev/null +++ b/.claude/rules/frontend/swift-language-guide.md @@ -0,0 +1,24 @@ +### Swift Concurrency 규칙 + +- 도메인 모델: `Sendable` 준수 +- 격리가 논리적으로 보장된다면 Sendable 키워드 사용 +- `@unchecked Sendable` NSLock 등 개념적으로 잘 구현된 경우가 아니라면 **사용 금지** +| Dependency Client struct | `Sendable` 준수 | +| Client closure 프로퍼티 | `@Sendable` 어노테이션 | + + +### 도메인 모델 작성 예시 +- Decoding -> Decoable +- Encoding -> Encodable +- Decoding & Encoding -> Codable +- domain 격리 보장시 -> Sendable +- UI에 사용 시 -> Hashable, Identifiable 등 UI가 요구하는 프로토콜 + +```swift +struct Person: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var name: String + var relationship: Relationship + var transactions: [Transaction] +} +``` \ No newline at end of file diff --git a/.claude/rules/frontend/test-strategy.md b/.claude/rules/frontend/test-strategy.md new file mode 100644 index 0000000..83f1190 --- /dev/null +++ b/.claude/rules/frontend/test-strategy.md @@ -0,0 +1,11 @@ +### 10.1 Reducer 테스트 + +- TCA `TestStore`를 사용하여 Reducer 로직을 검증한다 +- `withDependencies`로 Mock 의존성을 주입한다 +- `store.send()` → State 변경 검증 +- `store.receive()` → Effect 결과 검증 +- 코드 패턴: `/tca-test` skill 참조 + +### 10.2 유틸리티 테스트 + +- `Date+Extensions`, `Int+Extensions` 등 Extension 유틸 함수 단위 테스트 \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a0f1ba1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "deny": [ + "Bash(rm:*)" + ] + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96f46e8..16bb972 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: run: | mkdir -p Projects/App/Resources if [ ! -f Projects/App/Resources/Common.xcconfig ]; then - printf "BASE_URL = https://hellodoriworld.com\n" > Projects/App/Resources/Common.xcconfig + printf "BASE_URL = https://hellodoriworld.com\nKAKAO_NATIVE_APP_KEY =\n" > Projects/App/Resources/Common.xcconfig fi echo "✅ xcconfig file is ready" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d035b37 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# Dori-iOS 헤더 규칙 + +- 신규 생성 파일(`.swift`, `.xcconfig`)에는 아래 템플릿을 파일 최상단에 추가한다. +- 작성자명은 AI 사용 여부와 무관하게 반드시 `강동영`으로 유지한다. + +```swift +// +// {FileName} +// Dori-iOS +// +// Created by 강동영 on {M/d/yy}. +// +``` diff --git a/Project.swift b/Project.swift deleted file mode 100644 index f6505e8..0000000 --- a/Project.swift +++ /dev/null @@ -1,100 +0,0 @@ -import ProjectDescription - -let deploymentTarget: DeploymentTargets = .iOS("17.6") - -let commonSettings: SettingsDictionary = [ - "SWIFT_VERSION": "6.0", - "SWIFT_APPROACHABLE_CONCURRENCY": "YES", - "SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor", -] - -func framework( - name: String, - path: String, - dependencies: [TargetDependency] = [], - hasResources: Bool = false -) -> Target { - .target( - name: name, - destinations: .iOS, - product: .staticFramework, - bundleId: "com.arex.dori.\(name)", - deploymentTargets: deploymentTarget, - sources: ["\(path)/Sources/**"], - resources: hasResources ? ["\(path)/Resources/**"] : nil, - dependencies: dependencies, - settings: .settings(base: commonSettings) - ) -} - -func doriUnitTests( - name: String, - dependencies: [TargetDependency] = [] -) -> Target { - .target( - name: "\(name)Tests", - destinations: .iOS, - product: .unitTests, - bundleId: "com.arex.dori.\(name)Tests", - deploymentTargets: deploymentTarget, - sources: ["\(name)/Tests/**"], - dependencies: [.target(name: name)] + dependencies, - settings: .settings(base: commonSettings) - ) -} - -let project = Project( - name: "Dori-iOS", - organizationName: "com.arex", - settings: .settings(base: commonSettings), - targets: [ - // App - .target( - name: "DoriApp", - destinations: .iOS, - product: .app, - bundleId: "com.arex.dori", - deploymentTargets: deploymentTarget, - infoPlist: .extendingDefault(with: [ - "UILaunchScreen": .dictionary([:]), - "BASE_URL": "$(BASE_URL)", - "Appearance": "Light", - ]), - sources: ["Projects/App/Sources/**"], - resources: ["Projects/App/Resources/**"], - dependencies: [ - .target(name: "FeatureOnboarding"), - .target(name: "DoriNetworkImpl"), - .target(name: "DoriDesignSystem"), - .target(name: "DoriCore"), - ], - settings: .settings( - base: commonSettings, - configurations: [ - .debug(name: "Debug", xcconfig: "Projects/App/Resources/Common.xcconfig"), - .release(name: "Release", xcconfig: "Projects/App/Resources/Common.xcconfig"), - ] - ) - ), - - // Core - framework(name: "DoriCore", path: "Projects/Core/DoriCore"), - framework(name: "DoriDesignSystem", path: "Projects/Core/DoriDesignSystem", - dependencies: [.target(name: "DoriCore")], - hasResources: true), - - // Infra - framework(name: "DoriNetwork", path: "Projects/Infra/DoriNetwork"), - doriUnitTests(name: "DoriNetwork"), - framework(name: "DoriNetworkImpl", path: "Projects/Infra/DoriNetworkImpl", - dependencies: [ - .target(name: "DoriNetwork"), - .external(name: "Alamofire"), - ]), - - // Feature - framework(name: "FeatureOnboarding", path: "Projects/Feature/Onboarding", - dependencies: [.target(name: "DoriDesignSystem"), .target(name: "DoriCore")], - hasResources: true), - ] -) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift new file mode 100644 index 0000000..591b129 --- /dev/null +++ b/Projects/App/Project.swift @@ -0,0 +1,29 @@ +// +// Project.swift +// Dori-iOS +// +// Created by 강동영 on 2/12/26. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.dori( + targets: [ + DoriAppTarget.make( + dependencies: [ + DoriModules.onboarding.module.projectDependency, + DoriModules.calendar.module.projectDependency, + DoriModules.history.module.projectDependency, + DoriModules.myPage.module.projectDependency, + DoriModules.network.module.projectDependency, + DoriModules.networkImpl.module.projectDependency, + DoriModules.kakaoAuth.module.projectDependency, + DoriModules.keychain.module.projectDependency, + DoriModules.designSystem.module.projectDependency, + DoriModules.core.module.projectDependency, + DoriDependency.composableArchitecture, + ] + ), + ] +) diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index dacb18e..8e51364 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "IMG_1198 1.jpg", + "filename" : "appIcon.jpg", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Projects/App/Sources/AppFeature.swift b/Projects/App/Sources/AppFeature.swift new file mode 100644 index 0000000..38287b6 --- /dev/null +++ b/Projects/App/Sources/AppFeature.swift @@ -0,0 +1,74 @@ +// +// AppFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import SwiftUI +import ComposableArchitecture +import FeatureOnboarding + +@Reducer +struct AppFeature { + @ObservableState + struct State: Equatable { + enum Route: Equatable { + case splash + case intro + case mainTab + } + + var route: Route = .splash + var splash = SplashFeature.State() + var intro = IntroFeature.State() + var mainTab = MainTabFeature.State() + } + + enum Action: Equatable { + case splash(SplashFeature.Action) + case intro(IntroFeature.Action) + case mainTab(MainTabFeature.Action) + } + + var body: some ReducerOf { + Scope(state: \.splash, action: \.splash) { + SplashFeature() + } + Scope(state: \.intro, action: \.intro) { + IntroFeature() + } + Scope(state: \.mainTab, action: \.mainTab) { + MainTabFeature() + } + Reduce { state, action in + switch action { + case .splash(.delegate(.finished)): + state.route = .intro + return .none + + case .intro(.delegate(.loginSucceeded)): + state.route = .mainTab + return .none + + case .splash, .intro, .mainTab: + return .none + } + } + } +} + +struct AppView: View { + let store: StoreOf + + var body: some View { + switch store.route { + case .splash: + SplashView(store: store.scope(state: \.splash, action: \.splash)) + case .intro: + IntroView(store: store.scope(state: \.intro, action: \.intro)) + case .mainTab: + MainTabView(store: store.scope(state: \.mainTab, action: \.mainTab)) + } + } +} diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index d609a51..ffd1ab3 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -2,22 +2,58 @@ // DoriApp.swift // Dori-iOS // -// Created by 강동영 on 2/5/26. +// Created by 강동영 on 2/11/26. // import SwiftUI +import ComposableArchitecture import DoriDesignSystem +import DoriNetwork +import DoriNetworkImpl +import FeatureOnboarding +import PlatformKakaoAuth +import PlatformKeychain @main struct DoriApp: App { - + let store: StoreOf + init() { + let tokenStore = KeychainAuthTokenStore(service: DoriKeychainKey.serviceID) + let interceptor = AuthInterceptor(tokenStore: tokenStore) + #if DEBUG + let networkService = NetworkServiceImpl( + configuration: .default, + logger: NetworkLogger(), + interceptor: interceptor + ) + #else + let networkService = NetworkServiceImpl( + configuration: .default, + logger: Logger(), + interceptor: interceptor + ) + #endif + + self.store = Store(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.kakaoServerLoginClient = .live( + networkService: networkService, + tokenStore: tokenStore + ) + } + FontManager.registerAllFonts() + KakaoSDKHandler.initializeFromMainBundle() } - + var body: some Scene { WindowGroup { - MainTabView() + AppView(store: store) + .onOpenURL { url in + _ = KakaoSDKHandler.handleOpenURL(url) + } } } } diff --git a/Projects/App/Sources/MainTabView.swift b/Projects/App/Sources/MainTabView.swift index 3ed34bb..7169c17 100644 --- a/Projects/App/Sources/MainTabView.swift +++ b/Projects/App/Sources/MainTabView.swift @@ -2,37 +2,83 @@ // MainTabView.swift // Dori-iOS // -// Created by 강동영 on 2/5/26. +// Created by 강동영 on 2/11/26. // import SwiftUI +import ComposableArchitecture +import FeatureCalendar +import FeatureHistory +import FeatureMyPage + +@Reducer +struct MainTabFeature { + @ObservableState + struct State: Equatable { + var selectedTab: Tab = .calendar + var calendar = CalendarFeature.State() + var history = HistoryFeature.State() + var myPage = MyPageFeature.State() + + enum Tab: Equatable { + case calendar, history, myPage + } + } + + enum Action: Equatable { + case tabSelected(State.Tab) + case calendar(CalendarFeature.Action) + case history(HistoryFeature.Action) + case myPage(MyPageFeature.Action) + } + + var body: some ReducerOf { + Scope(state: \.calendar, action: \.calendar) { + CalendarFeature() + } + Scope(state: \.history, action: \.history) { + HistoryFeature() + } + Scope(state: \.myPage, action: \.myPage) { + MyPageFeature() + } + Reduce { state, action in + switch action { + case let .tabSelected(tab): + state.selectedTab = tab + return .none + + case .calendar, .history, .myPage: + return .none + } + } + } +} struct MainTabView: View { - @State private var selectedTab = 0 - + @Bindable var store: StoreOf + var body: some View { - TabView(selection: $selectedTab) { - Text("캘린더") - .tabItem { - Label("캘린더", systemImage: "calendar") - } - .tag(0) - - Text("내역") - .tabItem { - Label("내역", systemImage: "list.bullet.rectangle") - } - .tag(1) - - Text("마이페이지") - .tabItem { - Label("마이페이지", systemImage: "person.circle") - } - .tag(2) + TabView(selection: $store.selectedTab.sending(\.tabSelected)) { + CalendarView(store: store.scope(state: \.calendar, action: \.calendar)) + .tag(MainTabFeature.State.Tab.calendar) + .tabItem { Label("캘린더", systemImage: "calendar") } + + HistoryView(store: store.scope(state: \.history, action: \.history)) + .tag(MainTabFeature.State.Tab.history) + .tabItem { Label("내역", systemImage: "list.bullet.rectangle") } + + MyPageView(store: store.scope(state: \.myPage, action: \.myPage)) + .tag(MainTabFeature.State.Tab.myPage) + .tabItem { Label("마이페이지", systemImage: "person.circle") } } } } #Preview { - MainTabView() + MainTabView( + store: Store(initialState: MainTabFeature.State()) { + MainTabFeature() + } + ) } diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_300.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main.colorset/Contents.json similarity index 100% rename from Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_300.colorset/Contents.json rename to Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main.colorset/Contents.json diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_100.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Secondary.colorset/Contents.json similarity index 75% rename from Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_100.colorset/Contents.json rename to Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Secondary.colorset/Contents.json index 2877207..a9f9c9c 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_100.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFF", - "green" : "0xF1", - "red" : "0xE7" + "blue" : "0xAD", + "green" : "0x82", + "red" : "0x64" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/DoriWhite.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/DoriWhite.colorset/Contents.json index 97650a1..fafa476 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/DoriWhite.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/DoriWhite.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-100.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-100.colorset/Contents.json index b9cb151..0d72cc1 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-100.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-100.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF0", - "green" : "0xF0", - "red" : "0xF0" + "blue" : "0xF6", + "green" : "0xF4", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-200.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-200.colorset/Contents.json index cdbd517..7a0afd5 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-200.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-200.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE8", + "blue" : "0xEB", "green" : "0xE8", - "red" : "0xE8" + "red" : "0xE5" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-300.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-300.colorset/Contents.json index 78408a9..c7c2857 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-300.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-300.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xD9", - "green" : "0xD9", - "red" : "0xD9" + "blue" : "0xDB", + "green" : "0xD6", + "red" : "0xD1" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-400.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-400.colorset/Contents.json index a37482d..15a7473 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-400.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-400.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xCE", - "green" : "0xCE", - "red" : "0xCE" + "blue" : "0xC1", + "green" : "0xB8", + "red" : "0xB0" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_200.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-50.colorset/Contents.json similarity index 75% rename from Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_200.colorset/Contents.json rename to Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-50.colorset/Contents.json index 06af736..49492c2 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Brand/Main_200.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-50.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x85", - "green" : "0x48", - "red" : "0x32" + "blue" : "0xFB", + "green" : "0xFA", + "red" : "0xF9" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-500.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-500.colorset/Contents.json index fb0c529..9b9cf89 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-500.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-500.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xBB", - "green" : "0xBB", - "red" : "0xBB" + "blue" : "0xA1", + "green" : "0x95", + "red" : "0x8B" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-600.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-600.colorset/Contents.json index 1a27b94..6055f78 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-600.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-600.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x8D", - "green" : "0x8D", - "red" : "0x8D" + "blue" : "0x84", + "green" : "0x76", + "red" : "0x6B" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-700.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-700.colorset/Contents.json index 328b855..fb085b5 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-700.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-700.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x82", - "green" : "0x82", - "red" : "0x82" + "blue" : "0x68", + "green" : "0x59", + "red" : "0x4E" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-800.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-800.colorset/Contents.json index 5320a82..dac499c 100644 --- a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-800.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-800.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x64", - "green" : "0x64", - "red" : "0x64" + "blue" : "0x4B", + "green" : "0x3D", + "red" : "0x33" } }, "idiom" : "universal" diff --git a/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-900.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-900.colorset/Contents.json new file mode 100644 index 0000000..2397440 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Colors.xcassets/Greyscale/Grey-900.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x1F", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Font/SDSamliphopangcheTTFBasic.ttf b/Projects/Core/DoriDesignSystem/Resources/Font/SDSamliphopangcheTTFBasic.ttf new file mode 100644 index 0000000..82297ab Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Font/SDSamliphopangcheTTFBasic.ttf differ diff --git a/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Contents.json similarity index 51% rename from Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Contents.json index eb87897..73c0059 100644 --- a/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Contents.json @@ -1,9 +1,4 @@ { - "colors" : [ - { - "idiom" : "universal" - } - ], "info" : { "author" : "xcode", "version" : 1 diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_kakaoLogin.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_kakaoLogin.imageset/Contents.json new file mode 100644 index 0000000..6942706 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_kakaoLogin.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_kakaoLogin.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_kakaoLogin.imageset/icon_kakaoLogin.png b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_kakaoLogin.imageset/icon_kakaoLogin.png new file mode 100644 index 0000000..cca5407 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_kakaoLogin.imageset/icon_kakaoLogin.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_0.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_0.imageset/Contents.json new file mode 100644 index 0000000..080bdb1 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_0.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "onboarding_0.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_0.imageset/onboarding_0.png b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_0.imageset/onboarding_0.png new file mode 100644 index 0000000..86790e3 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_0.imageset/onboarding_0.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_1.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_1.imageset/Contents.json new file mode 100644 index 0000000..bf49d58 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "onboarding_1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_1.imageset/onboarding_1.png b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_1.imageset/onboarding_1.png new file mode 100644 index 0000000..52ca207 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_1.imageset/onboarding_1.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_2.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_2.imageset/Contents.json new file mode 100644 index 0000000..9a5b7bf --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "onboarding_2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_2.imageset/onboarding_2.png b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_2.imageset/onboarding_2.png new file mode 100644 index 0000000..6677ce4 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_2.imageset/onboarding_2.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_3.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_3.imageset/Contents.json new file mode 100644 index 0000000..61213f0 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "onboarding_3.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_3.imageset/onboarding_3.png b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_3.imageset/onboarding_3.png new file mode 100644 index 0000000..9db1873 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/Onboarding/onboarding_3.imageset/onboarding_3.png differ diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/DesignTokenDemoView.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/DesignTokenDemoView.swift index f9d21aa..f8de5ed 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/DesignTokenDemoView.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/DesignTokenDemoView.swift @@ -45,7 +45,7 @@ fileprivate struct DesignTokenDemoView: View { ZStack { Rectangle() .frame(height: 50) - .foregroundStyle(Color.grey100) + .foregroundStyle(.grey100) Text("도리 화이팅") .pretendard(.heading(heading)) } @@ -54,9 +54,9 @@ fileprivate struct DesignTokenDemoView: View { Text("헤딩") .padding() .pretendard(.heading(.h15)) - .foregroundStyle(Color.white) + .foregroundStyle(.doriWhite) .frame(maxWidth: .infinity) - .background(Color.main200) + .background(.secondary) .padding() } @@ -65,7 +65,7 @@ fileprivate struct DesignTokenDemoView: View { ZStack { Rectangle() .frame(height: 50) - .foregroundStyle(Color.grey200) + .foregroundStyle(.grey200) Text("도리 화이팅") .pretendard(.subtitle(subtitle)) } @@ -74,9 +74,9 @@ fileprivate struct DesignTokenDemoView: View { Text("서브 타이틀") .padding() .pretendard(.heading(.h15)) - .foregroundStyle(Color.white) + .foregroundStyle(.doriWhite) .frame(maxWidth: .infinity) - .background(Color.main200) + .background(.secondary) .padding() } @@ -85,7 +85,7 @@ fileprivate struct DesignTokenDemoView: View { ZStack { Rectangle() .frame(height: 50) - .foregroundStyle(Color.grey300) + .foregroundStyle(.grey300) Text("도리 화이팅") .pretendard(.body(body)) } @@ -94,9 +94,9 @@ fileprivate struct DesignTokenDemoView: View { Text("바디") .padding() .pretendard(.heading(.h15)) - .foregroundStyle(Color.white) + .foregroundStyle(.doriWhite) .frame(maxWidth: .infinity) - .background(Color.main200) + .background(.main) .padding() } @@ -105,7 +105,7 @@ fileprivate struct DesignTokenDemoView: View { ZStack { Rectangle() .frame(height: 50) - .foregroundStyle(Color.grey400) + .foregroundStyle(.grey400) Text("도리 화이팅") .pretendard(.caption(caption)) } @@ -114,9 +114,9 @@ fileprivate struct DesignTokenDemoView: View { Text("캡션") .padding() .pretendard(.heading(.h15)) - .foregroundStyle(Color.white) + .foregroundStyle(.doriWhite) .frame(maxWidth: .infinity) - .background(Color.main200) + .background(.main) .padding() } } @@ -125,22 +125,21 @@ fileprivate struct DesignTokenDemoView: View { extension DesignTokenDemoView { private static let brandColors: [Color] = [ - Color.main100, - Color.main200, - Color.main300 + DoriColors.main.color, + DoriColors.secondary.color, ] private static let greyColors: [Color] = [ - Color.white, - Color.grey100, - Color.grey200, - Color.grey300, - Color.grey400, - Color.grey500, - Color.grey600, - Color.grey700, - Color.grey800, - Color.black + DoriColors.doriWhite.color, + DoriColors.grey100.color, + DoriColors.grey200.color, + DoriColors.grey300.color, + DoriColors.grey400.color, + DoriColors.grey500.color, + DoriColors.grey600.color, + DoriColors.grey700.color, + DoriColors.grey800.color, + DoriColors.doriBlack.color ] private static let typoHeadings: [TypoStyle.Heading] = TypoStyle.Heading.allCases diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/FontManager.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/FontManager.swift index 2e90ab3..812779d 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/FontManager.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/FontManager.swift @@ -48,6 +48,7 @@ public enum FontManager { "Pretendard-Regular", "Pretendard-Medium", "Pretendard-SemiBold", + "SDSamliphopangcheTTFBasic" ] fontNames.forEach { registerFontIfNeeded($0) } } diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider+Impl.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider+Impl.swift new file mode 100644 index 0000000..8b42926 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider+Impl.swift @@ -0,0 +1,55 @@ +// +// FontProvider+Impl.swift +// DoriCore +// +// Created by 강동영 on 2/12/26. +// Copyright © 2026 com.arex. All rights reserved. +// + + +// MARK: - Pretendard Font Provider +public struct PretendardProvider: FontProvider { + public let regular: String = FontName.regular.name + public let medium: String = FontName.medium.name + public let semiBold: String = FontName.semiBold.name + public let bold: String? = FontName.bold.name + + public init() {} +} + +extension PretendardProvider { + enum FontName { + case regular, medium, semiBold, bold + + var name: String { + switch self { + case .regular: return "Pretendard-Regular" + case .medium: return "Pretendard-Medium" + case .semiBold: return "Pretendard-SemiBold" + case .bold: return "Pretendard-Bold" + } + } + } +} + +// MARK: - SamlipHopang Font Provider +public enum SamlipHopangProvider: FontProvider { + public var basic: String { + FontName.basic.name + } + + public var regular: String { basic } + public var medium: String { basic } + public var semiBold: String { basic } + public var bold: String? { basic} +} + +extension SamlipHopangProvider { + enum FontName { + case basic + + var name: String { + return "SDSamliphopangcheTTFBasic" + } + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider.swift index 20e861c..866a59c 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/FontProvider.swift @@ -15,31 +15,6 @@ public protocol FontProvider { var bold: String? { get } } -// MARK: - Pretendard Font Provider -public struct PretendardProvider: FontProvider { - public let regular: String = FontName.regular.name - public let medium: String = FontName.medium.name - public let semiBold: String = FontName.semiBold.name - public let bold: String? = FontName.bold.name - - public init() {} -} - -extension PretendardProvider { - enum FontName { - case regular, medium, semiBold, bold - - var name: String { - switch self { - case .regular: return "Pretendard-Regular" - case .medium: return "Pretendard-Medium" - case .semiBold: return "Pretendard-SemiBold" - case .bold: return "Pretendard-Bold" - } - } - } -} - // MARK: - Font Weight Enum public enum FontWeight { case regular, medium, semiBold, bold diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift index c9f8281..cbea099 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/TypoStyle.swift @@ -15,19 +15,19 @@ public enum TypoStyle { case caption(TypoStyle.Caption) // MARK: - 폰트에 의존하지 않는 스타일 정의 - private var styleSpec: (weight: FontWeight, size: CGFloat) { + private var styleSpec: (weight: FontWeight, size: CGFloat, lineHeight: CGFloat) { switch self { case .heading(let heading): - return (.bold, heading.size) + return (.bold, heading.size, heading.lineHeight) case .subtitle(let subtitle): - return (.semiBold, subtitle.size) + return (.semiBold, subtitle.size, subtitle.lineHeight) case .body(let body): - return (.medium, body.size) + return (.medium, body.size, body.lineHeight) case .caption(let caption): - return (.regular, caption.size) + return (.regular, caption.size, caption.lineHeight) } } @@ -35,7 +35,7 @@ public enum TypoStyle { public func getFontStyle(with provider: FontProvider) -> FontStyle { let spec = styleSpec let fontName = spec.weight.getFontName(from: provider) - return FontStyle(.custom(fontName), size: spec.size) + return FontStyle(.custom(fontName), size: spec.size, lineHeight: spec.lineHeight) } } @@ -44,23 +44,35 @@ public extension TypoStyle { case h11 = 11, h13 = 13, h14 = 14, h15 = 15, h16 = 16, h20 = 20, h30 = 30 var size: CGFloat { CGFloat(rawValue) } + + // 등차수열 적용 + var lineHeight: CGFloat { 2 * size - 6 } } enum SubTitle: Int, CaseIterable { - case t12 = 12, t14 = 14, t15 = 15, t16 = 16 + case t12 = 12, t14 = 14, t15 = 15, t16 = 16, t20 = 20 var size: CGFloat { CGFloat(rawValue) } + + // 등차수열 적용 + var lineHeight: CGFloat { 2 * size - 6 } } enum Body: Int, CaseIterable { case b11 = 11, b12, b13, b14, b15, b16 var size: CGFloat { CGFloat(rawValue) } + + // 등차수열 적용 + var lineHeight: CGFloat { 2 * size - 6 } } enum Caption: Int, CaseIterable { - case c11 = 11, c12, c13, c14, c15, c16, c20 = 20 + case c11 = 11, c12, c13, c14, c15, c16, c18 = 18, c20 = 20 var size: CGFloat { CGFloat(rawValue) } + + // 등차수열 적용 + var lineHeight: CGFloat { 2 * size - 6 } } } diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/View+.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/View+.swift index f111985..dec186e 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/View+.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/View+.swift @@ -16,4 +16,15 @@ public extension View { .font(fontStyle.font) .lineSpacing(fontStyle.lineHeight) } + + func hopangche( + size: CGFloat, + lineHeight: CGFloat = 55 + ) -> some View { + let fontName = SamlipHopangProvider.FontName.basic.name + let fontStyle = FontStyle(.custom(fontName), size: size, lineHeight: lineHeight) + return self + .font(fontStyle.font) + .lineSpacing(fontStyle.lineHeight) + } } diff --git a/Projects/Core/Project.swift b/Projects/Core/Project.swift new file mode 100644 index 0000000..f566ee1 --- /dev/null +++ b/Projects/Core/Project.swift @@ -0,0 +1,30 @@ +// +// Project.swift +// Dori-iOS +// +// Created by 강동영 on 2/12/26. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.dori( + name: DoriLayer.core.projectName, + targets: [ + .doriFramework(DoriModules.core.module), + .doriFramework( + DoriModules.designSystem.module, + dependencies: [ + DoriModules.core.module.targetDependency, + ], + hasResources: true + ), + ], + resourceSynthesizers: [ + .custom( + name: "Assets", + parser: .assets, + extensions: ["xcassets"] + ), + ] +) diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift new file mode 100644 index 0000000..07fc9c3 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -0,0 +1,43 @@ +// +// CalendarFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import ComposableArchitecture +import SwiftUI + +@Reducer +public struct CalendarFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + case onAppear + } + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onAppear: + return .none + } + } +} + +public struct CalendarView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Text("캘린더") + .onAppear { store.send(.onAppear) } + } +} diff --git a/Projects/Feature/History/Sources/HistoryFeature.swift b/Projects/Feature/History/Sources/HistoryFeature.swift new file mode 100644 index 0000000..d0981bf --- /dev/null +++ b/Projects/Feature/History/Sources/HistoryFeature.swift @@ -0,0 +1,43 @@ +// +// HistoryFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import ComposableArchitecture +import SwiftUI + +@Reducer +public struct HistoryFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + case onAppear + } + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onAppear: + return .none + } + } +} + +public struct HistoryView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Text("내역") + .onAppear { store.send(.onAppear) } + } +} diff --git a/Projects/Feature/MyPage/Sources/MyPageFeature.swift b/Projects/Feature/MyPage/Sources/MyPageFeature.swift new file mode 100644 index 0000000..64ec6ad --- /dev/null +++ b/Projects/Feature/MyPage/Sources/MyPageFeature.swift @@ -0,0 +1,43 @@ +// +// MyPageFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import ComposableArchitecture +import SwiftUI + +@Reducer +public struct MyPageFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + case onAppear + } + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onAppear: + return .none + } + } +} + +public struct MyPageView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Text("마이페이지") + .onAppear { store.send(.onAppear) } + } +} diff --git a/Projects/Feature/Onboarding/Sources/IntroFeature.swift b/Projects/Feature/Onboarding/Sources/IntroFeature.swift new file mode 100644 index 0000000..8ce301f --- /dev/null +++ b/Projects/Feature/Onboarding/Sources/IntroFeature.swift @@ -0,0 +1,84 @@ +// +// IntroFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation +import PlatformKakaoAuth +import DoriNetwork +import ComposableArchitecture + +@Reducer +public struct IntroFeature : Sendable { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public var isLoading = false + public var loginSucceeded = false + public var errorMessage: String? + + public init() {} + } + + public enum Action: Equatable, Sendable { + case kakaoLoginButtonTapped + case kakaoLoginResponse(Result) + case errorAlertDismissed + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case loginSucceeded + } + } + + @Dependency(KakaoAuthClient.self) var kakaoAuthClient + @Dependency(KakaoServerLoginClient.self) var kakaoServerLoginClient + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .kakaoLoginButtonTapped: + guard !state.isLoading else { return .none } + state.isLoading = true + state.errorMessage = nil + return .run { send in + do { + let kakaoAccessToken = try await kakaoAuthClient.login() + print("kakaoAccessToken: \(kakaoAccessToken)") + let loginResponse = try await kakaoServerLoginClient.login(kakaoAccessToken) + await send(.kakaoLoginResponse(.success(loginResponse))) + } catch { + await send(.kakaoLoginResponse(.failure(KakaoLoginFailure(error)))) + } + } + + case .kakaoLoginResponse(.success): + state.isLoading = false + state.loginSucceeded = true + return .send(.delegate(.loginSucceeded)) + + case let .kakaoLoginResponse(.failure(error)): + state.isLoading = false + state.errorMessage = error.message + return .none + + case .errorAlertDismissed: + state.errorMessage = nil + return .none + + case .delegate: + return .none + } + } +} + +public struct KakaoLoginFailure: Error, Equatable, Sendable { + public let message: String + + public init(_ error: Error) { + let description = (error as NSError).localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + self.message = description.isEmpty ? "로그인 중 오류가 발생했습니다." : description + } +} diff --git a/Projects/Feature/Onboarding/Sources/IntroView.swift b/Projects/Feature/Onboarding/Sources/IntroView.swift new file mode 100644 index 0000000..3cb5b64 --- /dev/null +++ b/Projects/Feature/Onboarding/Sources/IntroView.swift @@ -0,0 +1,215 @@ +// +// IntroView.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import SwiftUI +import DoriDesignSystem +import ComposableArchitecture + +struct IntroProps: Identifiable { + let id: Int + let title: String + let subtitle: String + let image: DoriImages +} + +extension [IntroProps] { + nonisolated(unsafe) static let onboarding: [IntroProps] = [ + .init( + id: 0, + title: "도리", + subtitle: "돈보다 깊은 마음, 도리로 남다", + image: .onboarding0 + ), + .init( + id: 1, + title: "주고 받은 도리를", + subtitle: "그래프로 확인해보세요", + image: .onboarding1 + ), + .init( + id: 2, + title: "누구와 주고 받았는지", + subtitle: "상세 내역도 확인 가능해요", + image: .onboarding2 + ), + .init( + id: 3, + title: "캘린더를 통해", + subtitle: "도리 내역을 한눈에 볼 수 있어요", + image: .onboarding3 + ), + ] +} + +public struct IntroView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + @State private var currentIndex: Int? = nil + + private let props: [IntroProps] = .onboarding + public var body: some View { + ZStack { + DoriColors.doriWhite.color + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + onbordingView + .overlay(alignment: .bottom) { + PageIndicator( + count: props.count, + currentIndex: $currentIndex + ) + .offset(y: 30) + } + Spacer() + + kakaoLoginButton + } + .alert( + "카카오 로그인 실패", + isPresented: Binding( + get: { store.errorMessage != nil }, + set: { isPresented in + if !isPresented { + store.send(.errorAlertDismissed) + } + } + ) + ) { + Button("확인", role: .cancel) {} + } message: { + Text(store.errorMessage ?? "알 수 없는 오류가 발생했습니다.") + } + } + } + + var onbordingView: some View { + ScrollView(.horizontal) { + LazyHStack(alignment:.bottom, spacing: 0) { + ForEach(props) { prop in + VStack(spacing: 40) { + + VStack(spacing: 0) { + if prop.id == 0 { + Text(prop.title) + .hopangche(size: 55) + .foregroundStyle(.main) + Text(prop.subtitle) + .pretendard(.caption(.c18)) + .foregroundStyle(.main) + } else { + Text(prop.title) + .pretendard(.subtitle(.t20)) + .foregroundStyle(.main) + Text(prop.subtitle) + .pretendard(.subtitle(.t20)) + .foregroundStyle(.main) + } + + } + + prop.image.image + .resizable() + .frame( + width: 240, + height: 240 + ) + } + .containerRelativeFrame([.horizontal]) + .tag(prop.id) + } + } + .scrollTargetLayout() + } + .frame(height: 350) + .padding(.horizontal, 60) + .scrollTargetBehavior(.paging) + .scrollPosition(id: $currentIndex) + } + + var kakaoLoginButton: some View { + Button { + store.send(.kakaoLoginButtonTapped) + } label: { + Text("카카오로 시작하기") + .pretendard(.subtitle(.t15)) + .foregroundStyle(DoriColors.doriBlack.color) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background( + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(.yellow) + ) + .overlay() { + HStack { + DoriIcons.iconKakaoLogin.image + .resizable() + .frame( + width: 24, + height: 24 + ) + .padding(.leading, 16) + + Spacer() + } + + } + } + .disabled(store.isLoading) + .padding() + } +} + +struct PageIndicator: View { + let count: Int + @Binding var currentIndex: Int? + + init( + count: Int, + currentIndex: Binding + ) { + self.count = count + if currentIndex.wrappedValue == nil { + self._currentIndex = .constant(0) + } else { + self._currentIndex = currentIndex + } + + } + + var body: some View { + HStack { + ForEach(0.. SocialLoginResponse + + public init(login: @escaping @Sendable (_ kakaoAccessToken: String) async throws -> SocialLoginResponse) { + self.login = login + } +} + +private enum KakaoServerLoginClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + + var errorDescription: String? { + switch self { + case .unconfigured: + return "KakaoServerLoginClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + } + } +} + +extension KakaoServerLoginClient: DependencyKey { + public static let liveValue = Self( + login: { _ in + throw KakaoServerLoginClientError.unconfigured + } + ) + + public static let testValue = Self( + login: { _ in + SocialLoginResponse( + accessToken: "test-jwt", + refreshToken: "test-refresh", + id: 1 + ) + } + ) +} + +public extension DependencyValues { + var kakaoServerLoginClient: KakaoServerLoginClient { + get { self[KakaoServerLoginClient.self] } + set { self[KakaoServerLoginClient.self] = newValue } + } +} + +public extension KakaoServerLoginClient { + static func live( + networkService: any NetworkService, + tokenStore: any AuthTokenStoring + ) -> Self { + Self( + login: { kakaoAccessToken in + let endpoint = KakaoLoginEndpoint(accessToken: kakaoAccessToken) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + + if let apiError = response.error { + throw KakaoServerLoginClientError.backendError( + apiError.message ?? apiError.code + ) + } + + guard response.success, let data = response.data else { + throw KakaoServerLoginClientError.invalidResponse + } + + print("token save accessToken: \(data.accessToken)") + print("token save refreshToken: \(data.refreshToken)") + try tokenStore.save(accessToken: data.accessToken, refreshToken: data.refreshToken) + return data + } + ) + } +} diff --git a/Projects/Feature/Onboarding/Sources/SplashView.swift b/Projects/Feature/Onboarding/Sources/SplashView.swift index 32cd61a..84b0363 100644 --- a/Projects/Feature/Onboarding/Sources/SplashView.swift +++ b/Projects/Feature/Onboarding/Sources/SplashView.swift @@ -2,17 +2,90 @@ // SplashView.swift // Dori-iOS // -// Created by 강동영 on 2/10/26. +// Created by 강동영 on 2/11/26. +// Copyright © 2026 com.arex. All rights reserved. // import SwiftUI +import DoriDesignSystem +import ComposableArchitecture -struct SplashView: View { - var body: some View { - Text("Splash View") +public struct SplashView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + + } + private let prop: IntroProps = [IntroProps].onboarding[0] + + private let imageSize: CGFloat = 240 + public var body: some View { + ZStack { + DoriColors.doriWhite.color + .ignoresSafeArea() + + VStack(spacing: 40) { + VStack(spacing: 0) { + Text(prop.title) + .hopangche(size: 55) + .foregroundStyle(.main) + Text(prop.subtitle) + .pretendard(.caption(.c18)) + .foregroundStyle(.main) + } + + prop.image.image + .resizable() + .frame( + width: imageSize, + height: imageSize + ) + + Spacer() + .frame(height: imageSize/2) + } + } + .onAppear { + store.send(.isAppeared) + } + } } -#Preview { - SplashView() +@Reducer +public struct SplashFeature : Sendable { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + case isAppeared + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case finished + } + } + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .isAppeared: + return .run { send in + try await Task.sleep(for: .seconds(1.5)) + await send(.delegate(.finished)) + } + case .delegate: + return .none + } + } } +// +//#Preview { +// SplashView(store: Store(initialState: SplashFeature.State(), reducer: { +// SplashFeature() +// })) +//} diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift new file mode 100644 index 0000000..89a0884 --- /dev/null +++ b/Projects/Feature/Project.swift @@ -0,0 +1,37 @@ +// +// Project.swift +// Dori-iOS +// +// Created by 강동영 on 2/12/26. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.dori( + name: DoriLayer.feature.projectName, + targets: [ + .doriFramework( + DoriModules.onboarding.module, + dependencies: [ + DoriModules.designSystem.module.projectDependency, + DoriModules.core.module.projectDependency, + DoriModules.network.module.projectDependency, + DoriModules.kakaoAuth.module.projectDependency, + DoriDependency.composableArchitecture, + ] + ), + .doriFramework( + DoriModules.calendar.module, + dependencies: [DoriDependency.composableArchitecture] + ), + .doriFramework( + DoriModules.history.module, + dependencies: [DoriDependency.composableArchitecture] + ), + .doriFramework( + DoriModules.myPage.module, + dependencies: [DoriDependency.composableArchitecture] + ), + ] +) diff --git a/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift new file mode 100644 index 0000000..66ddb99 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift @@ -0,0 +1,22 @@ +// +// AuthEndpoints.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation + +public struct KakaoLoginEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/auth/login/kakao" + public let method: HTTPMethod = .POST + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? + + public init(accessToken: String, baseURL: String = NetworkConfig.baseURL) { + self.baseURL = baseURL + self.body = try? JSONEncoder().encode(KakaoLoginRequest(accessToken: accessToken)) + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift b/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift new file mode 100644 index 0000000..c36b6d0 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift @@ -0,0 +1,16 @@ +// +// AuthRequests.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation + +public struct KakaoLoginRequest: Codable, Equatable, Sendable { + public let accessToken: String + + public init(accessToken: String) { + self.accessToken = accessToken + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Auth/AuthTokenStoring.swift b/Projects/Infra/DoriNetwork/Sources/Auth/AuthTokenStoring.swift new file mode 100644 index 0000000..14d7c0a --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Auth/AuthTokenStoring.swift @@ -0,0 +1,15 @@ +// +// AuthTokenStoring.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation + +public protocol AuthTokenStoring: Sendable { + func save(accessToken: String, refreshToken: String?) throws + func load() -> (accessToken: String?, refreshToken: String?) + func clear() throws + func exists() throws -> Bool +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift new file mode 100644 index 0000000..3548975 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift @@ -0,0 +1,26 @@ +// +// AuthResponses.swift +// Dori-iOS +// +// Created by 강동영 on 2/10/26. +// + +import Foundation + +// MARK: - Auth Response DTOs + +public struct SocialLoginResponse: Codable, Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + public let id: Int64 + + public init( + accessToken: String, + refreshToken: String, + id: Int64 + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.id = id + } +} diff --git a/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift b/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift new file mode 100644 index 0000000..54b9367 --- /dev/null +++ b/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift @@ -0,0 +1,43 @@ +// +// AuthInterceptor.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation +import Alamofire +import DoriNetwork + +public final class AuthInterceptor: RequestInterceptor { + private let authorizationKey = "Authorization" + private let tokenStore: any AuthTokenStoring + + public init(tokenStore: any AuthTokenStoring) { + self.tokenStore = tokenStore + } + + public func adapt( + _ urlRequest: URLRequest, + for session: Session, + completion: @escaping (Result) -> Void + ) { + var request = urlRequest + let accessToken = tokenStore.load().accessToken + + if let accessToken, !accessToken.isEmpty { + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: authorizationKey) + } + + completion(.success(request)) + } + + public func retry( + _ request: Request, + for session: Session, + dueTo error: any Error, + completion: @escaping @Sendable (RetryResult) -> Void + ) { + completion(.doNotRetryWithError(error)) + } +} diff --git a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift index 57b510d..cb76177 100644 --- a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift +++ b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift @@ -16,9 +16,13 @@ public final class NetworkServiceImpl: NetworkService { private let decoder: JSONDecoder private let logger: NetworkLogable? - public init(configuration: URLSessionConfiguration, logger: NetworkLogable? = nil) { + public init( + configuration: URLSessionConfiguration, + logger: NetworkLogable? = nil, + interceptor: RequestInterceptor? = nil + ) { configuration.timeoutIntervalForRequest = 20 - self.session = Session(configuration: configuration) + self.session = Session(configuration: configuration, interceptor: interceptor) self.logger = logger self.decoder = JSONDecoder() } diff --git a/Projects/Infra/Project.swift b/Projects/Infra/Project.swift new file mode 100644 index 0000000..b8e90f8 --- /dev/null +++ b/Projects/Infra/Project.swift @@ -0,0 +1,24 @@ +// +// Project.swift +// Dori-iOS +// +// Created by 강동영 on 2/12/26. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.dori( + name: DoriLayer.infra.projectName, + targets: [ + .doriFramework(DoriModules.network.module), + .doriUnitTests(DoriModules.network.module), + .doriFramework( + DoriModules.networkImpl.module, + dependencies: [ + DoriModules.network.module.targetDependency, + DoriDependency.alamofire, + ] + ), + ] +) diff --git a/Projects/Platform/KakaoAuth/Sources/KakaoAuthClient.swift b/Projects/Platform/KakaoAuth/Sources/KakaoAuthClient.swift new file mode 100644 index 0000000..06b67ae --- /dev/null +++ b/Projects/Platform/KakaoAuth/Sources/KakaoAuthClient.swift @@ -0,0 +1,87 @@ +// +// KakaoAuthClient.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation +import ComposableArchitecture +import KakaoSDKUser + +public struct KakaoAuthClient: Sendable { + public var login: @Sendable () async throws -> String + + public init(login: @escaping @Sendable () async throws -> String) { + self.login = login + } +} + +extension KakaoAuthClient: DependencyKey { + public static let liveValue = Self( + login: { + if UserApi.isKakaoTalkLoginAvailable() { + return try await loginWithKakaoTalk() + } + return try await loginWithKakaoAccount() + } + ) + + public static let testValue = Self( + login: { "test-access-token" } + ) + + private enum KakaoAuthClientError: LocalizedError { + case missingToken + + var errorDescription: String? { + switch self { + case .missingToken: + return "카카오 인증 토큰을 가져올 수 없습니다." + } + } + } + + @MainActor + private static func loginWithKakaoTalk() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + UserApi.shared.loginWithKakaoTalk { token, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let accessToken = token?.accessToken else { + continuation.resume(throwing: KakaoAuthClientError.missingToken) + return + } + continuation.resume(returning: accessToken) + } + } + } + + @MainActor + private static func loginWithKakaoAccount() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + UserApi.shared.loginWithKakaoAccount { token, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let accessToken = token?.accessToken else { + continuation.resume(throwing: KakaoAuthClientError.missingToken) + return + } + continuation.resume(returning: accessToken) + } + } + } +} + +public extension DependencyValues { + var kakaoAuthClient: KakaoAuthClient { + get { self[KakaoAuthClient.self] } + set { self[KakaoAuthClient.self] = newValue } + } +} diff --git a/Projects/Platform/KakaoAuth/Sources/KakaoSDKHandler.swift b/Projects/Platform/KakaoAuth/Sources/KakaoSDKHandler.swift new file mode 100644 index 0000000..1952dad --- /dev/null +++ b/Projects/Platform/KakaoAuth/Sources/KakaoSDKHandler.swift @@ -0,0 +1,30 @@ +// +// KakaoSDKHandler.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation +import KakaoSDKAuth +import KakaoSDKCommon + +public enum KakaoSDKHandler { + public static func initializeFromMainBundle(infoKey: String = "KAKAO_NATIVE_APP_KEY") { + guard + let appKey = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String, + !appKey.isEmpty + else { + assertionFailure("\(infoKey) is missing in Info.plist") + return + } + + KakaoSDK.initSDK(appKey: appKey) + } + + @MainActor @discardableResult + public static func handleOpenURL(_ url: URL) -> Bool { + guard AuthApi.isKakaoTalkLoginUrl(url) else { return false } + return AuthController.handleOpenUrl(url: url) + } +} diff --git a/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift b/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift new file mode 100644 index 0000000..a02252b --- /dev/null +++ b/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift @@ -0,0 +1,22 @@ +// +// DoriKeychainKey.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation + +public enum DoriKeychainKey: Sendable { + public static let serviceID = Bundle.main.bundleIdentifier ?? "com.arex.dori" + + case accessToken + case refreshToken + + public var rawValue: String { + switch self { + case .accessToken: return "access_token" + case .refreshToken: return "refresh_token" + } + } +} diff --git a/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift b/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift new file mode 100644 index 0000000..92c5cd7 --- /dev/null +++ b/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift @@ -0,0 +1,143 @@ +// +// KeychainAuthTokenStore.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation +import Security +import DoriNetwork + +public struct KeychainAuthTokenStore: AuthTokenStoring { + private let service: String + + public init(service: String) { + self.service = service + } + + public func save(accessToken: String, refreshToken: String?) throws { + try set(accessToken, for: .accessToken) + if let refreshToken { + try set(refreshToken, for: .refreshToken) + } + } + + public func load() -> (accessToken: String?, refreshToken: String?) { + let accessToken = try? string(for: .accessToken) + let refreshToken = try? string(for: .refreshToken) + return (accessToken, refreshToken) + } + + public func clear() throws { + _ = try? delete(.accessToken) + _ = try? delete(.refreshToken) + } + + public func exists() throws -> Bool { + try contains(.accessToken) + } +} + +private extension KeychainAuthTokenStore { + func set(_ value: String, for key: DoriKeychainKey) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.invalidData + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + kSecValueData as String: data, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + do { + try handleError(status) + } catch KeychainError.duplicateItem { + try updateExisting(data, for: key) + } + } + + func updateExisting(_ data: Data, for key: DoriKeychainKey) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + + let attributes: [String: Any] = [kSecValueData as String: data] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + try handleError(status) + } + + func string(for key: DoriKeychainKey) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + try handleError(status) + + guard let data = item as? Data else { + throw KeychainError.unexpectedPasswordData + } + + guard let string = String(data: data, encoding: .utf8) else { + throw KeychainError.unexpectedPasswordData + } + + return string + } + + func delete(_ key: DoriKeychainKey) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + + let status = SecItemDelete(query as CFDictionary) + if status == errSecItemNotFound { + return + } + try handleError(status) + } + + func contains(_ key: DoriKeychainKey) throws -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + switch status { + case errSecSuccess: + return true + case errSecItemNotFound: + return false + default: + throw KeychainError.unexpected(status) + } + } + + func handleError(_ status: OSStatus) throws { + switch status { + case errSecSuccess: + return + case errSecDuplicateItem: + throw KeychainError.duplicateItem + case errSecItemNotFound: + throw KeychainError.itemNotFound + default: + throw KeychainError.unexpected(status) + } + } +} diff --git a/Projects/Platform/Keychain/Sources/KeychainError.swift b/Projects/Platform/Keychain/Sources/KeychainError.swift new file mode 100644 index 0000000..a3ceb82 --- /dev/null +++ b/Projects/Platform/Keychain/Sources/KeychainError.swift @@ -0,0 +1,30 @@ +// +// KeychainError.swift +// Dori-iOS +// +// Created by 강동영 on 2/11/26. +// + +import Foundation + +public enum KeychainError: Error { + case itemNotFound + case duplicateItem + case invalidData + case unexpected(OSStatus) +} + +extension KeychainError: LocalizedError { + public var errorDescription: String? { + switch self { + case .itemNotFound: + return "아이템을 찾을 수 없습니다." + case .duplicateItem: + return "이미 존재하는 아이템입니다." + case .invalidData: + return "유효하지 않은 데이터입니다." + case .unexpected(let status): + return "Keychain 에러: \(status)" + } + } +} diff --git a/Projects/Platform/Project.swift b/Projects/Platform/Project.swift new file mode 100644 index 0000000..c47d7e9 --- /dev/null +++ b/Projects/Platform/Project.swift @@ -0,0 +1,30 @@ +// +// Project.swift +// Dori-iOS +// +// Created by 강동영 on 2/12/26. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.dori( + name: DoriLayer.platform.projectName, + targets: [ + .doriFramework( + DoriModules.kakaoAuth.module, + dependencies: [ + DoriDependency.composableArchitecture, + DoriDependency.kakaoSDKCommon, + DoriDependency.kakaoSDKAuth, + DoriDependency.kakaoSDKUser, + ] + ), + .doriFramework( + DoriModules.keychain.module, + dependencies: [ + DoriModules.network.module.projectDependency, + ] + ), + ] +) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 9f859d7..7e02091 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -5,15 +5,23 @@ import PackageDescription import struct ProjectDescription.PackageSettings let packageSettings = PackageSettings( - productTypes: [ - "Alamofire": .staticFramework, - ] + productTypes: [ + "ComposableArchitecture": .framework, + "Swinject": .framework, + "Alamofire": .framework, + "KakaoSDKCommon": .framework, + "KakaoSDKAuth": .framework, + "KakaoSDKUser": .framework, + ] ) #endif let package = Package( - name: "DoriDependencies", - dependencies: [ - .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.11.1"), - ] + name: "DoriDependencies", + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"), + .package(url: "https://github.com/Swinject/Swinject.git", from: "2.9.1"), + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.11.1"), + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.0.0"), + ] ) diff --git a/Tuist/ProjectDescriptionHelpers/DoriManifest.swift b/Tuist/ProjectDescriptionHelpers/DoriManifest.swift new file mode 100644 index 0000000..96b1c41 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/DoriManifest.swift @@ -0,0 +1,38 @@ +import ProjectDescription + +public enum DoriManifest { + public static let projectName = "Dori-iOS" + public static let organizationName = "com.arex" + public static let bundleIDPrefix = "com.arex.dori" + public static var deploymentTarget: DeploymentTargets { + .iOS("17.6") + } + + public static var commonSettings: SettingsDictionary { + [ + "SWIFT_VERSION": "6.0", + ] + } +} + +public enum DoriDependency { + public static var alamofire: TargetDependency { + .external(name: "Alamofire") + } + + public static var composableArchitecture: TargetDependency { + .external(name: "ComposableArchitecture") + } + + public static var kakaoSDKCommon: TargetDependency { + .external(name: "KakaoSDKCommon") + } + + public static var kakaoSDKAuth: TargetDependency { + .external(name: "KakaoSDKAuth") + } + + public static var kakaoSDKUser: TargetDependency { + .external(name: "KakaoSDKUser") + } +} diff --git a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift new file mode 100644 index 0000000..1b2658a --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift @@ -0,0 +1,194 @@ +import ProjectDescription + +public enum DoriLayer: String, CaseIterable, Sendable { + case app = "App" + case core = "Core" + case infra = "Infra" + case platform = "Platform" + case feature = "Feature" + + /// Root-relative project path (Workspace.swift용) + public var projectPath: Path { + "Projects/\(rawValue)" + } + + /// Per-layer Project.swift의 project name + public var projectName: String { + switch self { + case .app: DoriManifest.projectName + default: "Dori\(rawValue)" + } + } +} + +public struct DoriModule: Sendable { + public let name: String + public let layer: DoriLayer + public let directoryName: String + + public init(name: String, layer: DoriLayer, directoryName: String? = nil) { + self.name = name + self.layer = layer + self.directoryName = directoryName ?? name + } + + /// Root-relative path (Workspace 참조용) + public var path: String { + "Projects/\(layer.rawValue)/\(directoryName)" + } + + /// Layer-relative path (per-layer Project.swift 내부용) + public var localPath: String { + directoryName + } + + /// 같은 Project 내 타겟 의존성 + public var targetDependency: TargetDependency { + .target(name: name) + } + + /// 다른 Project에서 참조할 때 사용하는 크로스 프로젝트 의존성 + public var projectDependency: TargetDependency { + .project( + target: name, + path: "../\(layer.rawValue)" + ) + } +} + +public enum DoriModules: CaseIterable, Sendable { + case core + case designSystem + case network + case networkImpl + case kakaoAuth + case keychain + case onboarding + case calendar + case history + case myPage + + public var module: DoriModule { + switch self { + case .core: + DoriModule(name: "DoriCore", layer: .core) + case .designSystem: + DoriModule(name: "DoriDesignSystem", layer: .core) + case .network: + DoriModule(name: "DoriNetwork", layer: .infra) + case .networkImpl: + DoriModule(name: "DoriNetworkImpl", layer: .infra) + case .kakaoAuth: + DoriModule(name: "PlatformKakaoAuth", layer: .platform, directoryName: "KakaoAuth") + case .keychain: + DoriModule(name: "PlatformKeychain", layer: .platform, directoryName: "Keychain") + case .onboarding: + DoriModule(name: "FeatureOnboarding", layer: .feature, directoryName: "Onboarding") + case .calendar: + DoriModule(name: "FeatureCalendar", layer: .feature, directoryName: "Calendar") + case .history: + DoriModule(name: "FeatureHistory", layer: .feature, directoryName: "History") + case .myPage: + DoriModule(name: "FeatureMyPage", layer: .feature, directoryName: "MyPage") + } + } +} + +public enum DoriAppTarget { + public static let name = "DoriApp" + + private static let path = "." + private static let rootPath = "Projects/App" + private static let xcconfigPath = "\(rootPath)/Resources/Common.xcconfig" + + public static func make(dependencies: [TargetDependency]) -> Target { + .target( + name: name, + destinations: .iOS, + product: .app, + bundleId: DoriManifest.bundleIDPrefix, + deploymentTargets: DoriManifest.deploymentTarget, + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": .dictionary([:]), + "BASE_URL": "$(BASE_URL)", + "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", + "Appearance": "Light", + "CFBundleURLTypes": [ + [ + "CFBundleTypeRole": "Editor", + "CFBundleURLName": Plist.Value.string(DoriManifest.bundleIDPrefix), + "CFBundleURLSchemes": ["$(KAKAO_CAllBACK)"], + ], + ], + "LSApplicationQueriesSchemes": [ + "kakaokompassauth", + "kakaolink", + ], + ]), + sources: ["Sources/**"], + resources: [.glob(pattern: "Resources/**", excluding: ["Resources/info.plist"])], + dependencies: dependencies, + settings: .settings( + base: DoriManifest.commonSettings, + configurations: [ + .debug(name: "Debug", xcconfig: .relativeToRoot(xcconfigPath)), + .release(name: "Release", xcconfig: .relativeToRoot(xcconfigPath)), + ] + ) + ) + } +} + +public extension Target { + static func doriFramework( + _ module: DoriModule, + dependencies: [TargetDependency] = [], + hasResources: Bool = false + ) -> Target { + .target( + name: module.name, + destinations: .iOS, + product: .framework, + bundleId: "\(DoriManifest.bundleIDPrefix).\(module.name)", + deploymentTargets: DoriManifest.deploymentTarget, + sources: ["\(module.localPath)/Sources/**"], + resources: hasResources ? ["\(module.localPath)/Resources/**"] : nil, + dependencies: dependencies, + settings: .settings(base: DoriManifest.commonSettings) + ) + } + + static func doriUnitTests( + _ module: DoriModule, + dependencies: [TargetDependency] = [] + ) -> Target { + .target( + name: "\(module.name)Tests", + destinations: .iOS, + product: .unitTests, + bundleId: "\(DoriManifest.bundleIDPrefix).\(module.name)Tests", + deploymentTargets: DoriManifest.deploymentTarget, + sources: ["\(module.localPath)/Tests/**"], + dependencies: [.target(name: module.name)] + dependencies, + settings: .settings(base: DoriManifest.commonSettings) + ) + } +} + +public extension Project { + static func dori( + name: String = DoriManifest.projectName, + packages: [Package] = [], + targets: [Target], + resourceSynthesizers: [ResourceSynthesizer] = [] + ) -> Project { + Project( + name: name, + organizationName: DoriManifest.organizationName, + packages: packages, + settings: .settings(base: DoriManifest.commonSettings), + targets: targets, + resourceSynthesizers: resourceSynthesizers + ) + } +} diff --git a/Tuist/ResourceSynthesizers/Assets.stencil b/Tuist/ResourceSynthesizers/Assets.stencil new file mode 100644 index 0000000..11e10e7 --- /dev/null +++ b/Tuist/ResourceSynthesizers/Assets.stencil @@ -0,0 +1,128 @@ +// swiftlint:disable all +// swift-format-ignore-file +// swiftformat:disable all +// Generated using tuist — https://github.com/tuist/tuist +{% if catalogs %} +{% set enumName %}UIAsset{% endset %} +{% set colorType %}{{param.name}}Colors{% endset %} +{% set dataType %}{{param.name}}Data{% endset %} +{% set imageType %}{{param.name}}Images{% endset %} +{% set iconType %}{{param.name}}Icons{% endset %} +{% set forceNamespaces %}{{param.forceProvidesNamespaces|default:"false"}}{% endset %} +{% set bundleToken %}{{param.name}}Resources{% endset %} +{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} +{% set rawValueProtocol %}: String{% endset %} +{% set colorEnumName %}Colors{% endset %} +{% set imageEnumName %}Images{% endset %} +{% set iconEnumName %}Icons{% endset %} + +import SwiftUI + +// swiftlint:disable superfluous_disable_command file_length implicit_return +// MARK: - Asset Catalogs + +public typealias DoriColors = UIAsset.Colors +public typealias DoriImages = UIAsset.Images +public typealias DoriIcons = UIAsset.Icons + + +public extension View { + func foregroundStyle(_ colors: {{enumName}}.Colors) -> some View { + self.foregroundStyle(colors.color) + } + + func background(_ colors: UIAsset.Colors) -> some View { + self.background(colors.color) + } +} + +public extension Image { + init(_ images: {{enumName}}.Images) { + self = images.image + } +} + +{% macro enumBlock assets %} + {% call casesBlock assets %} + {% if param.allValues %} + // swiftlint:disable trailing_comma + {% if resourceCount.color > 0 %} + {{accessModifier}} static let allColors: [{{colorType}}] = [ + {% filter indent:2 %}{% call allValuesBlock assets "color" "" %}{% endfilter %} + ] + {% endif %} + {% if resourceCount.data > 0 %} + {{accessModifier}} static let allDataAssets: [{{dataType}}] = [ + {% filter indent:2 %}{% call allValuesBlock assets "data" "" %}{% endfilter %} + ] + {% endif %} + {% if resourceCount.image > 0 %} + {{accessModifier}} static let allImages: [{{imageType}}] = [ + {% filter indent:2 %}{% call allValuesBlock assets "image" "" %}{% endfilter %} + ] + + {% endif %} + {% if resourceCount.image > 0 %} + {{accessModifier}} static let allIcons: [{{iconType}}] = [ + {% filter indent:2 %}{% call allValuesBlock assets "icon" "" %}{% endfilter %} + ] + {% endif %} + // swiftlint:enable trailing_comma + {% endif %} +{% endmacro %} + +{% macro casesBlock assets %} + {% for asset in assets %} + {% if asset.type == "color" %} + case {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = "{{asset.value}}" + {% elif asset.type == "data" %} + {{accessModifier}} static let {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{dataType}}(name: "{{asset.value}}") + {% elif asset.type == "image" %} + case {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = "{{asset.value}}" + {% elif asset.items and ( forceNamespaces == "true" or asset.isNamespaced == "true" ) %} + {{accessModifier}} enum {{asset.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { + {% filter indent:2 %}{% call casesBlock asset.items %}{% endfilter %} + } + {% elif asset.items %} + {% call casesBlock asset.items %} + {% endif %} + {% endfor %} +{% endmacro %} + +{% macro allValuesBlock assets filter prefix %} + {% for asset in assets %} + {% if asset.type == filter %} + {{prefix}}{{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}, + {% elif asset.items and ( forceNamespaces == "true" or asset.isNamespaced == "true" ) %} + {% set prefix2 %}{{prefix}}{{asset.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}.{% endset %} + {% call allValuesBlock asset.items filter prefix2 %} + {% elif asset.items %} + {% call allValuesBlock asset.items filter prefix %} + {% endif %} + {% endfor %} +{% endmacro %} + +// swiftlint:disable identifier_name line_length nesting type_body_length type_name +{{accessModifier}} enum {{enumName}} { + {% if catalogs.count > 1 or param.forceFileNameEnum %} + {% for catalog in catalogs %} + {{accessModifier}} enum {{catalog.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {% if catalog.name == imageEnumName or catalog.name == colorEnumName or catalog.name == iconEnumName%}{{rawValueProtocol}} {% endif %} { + {% filter indent:2 %} + {% call enumBlock catalog.assets %} + {% endfilter %} + + {% if catalog.name == colorEnumName %} + public var color: Color { Color(self.rawValue, bundle: {{bundleToken}}.bundle) } + public var uiColor: UIColor { UIColor(named: self.rawValue, in: {{bundleToken}}.bundle, compatibleWith: nil)! } + {% elif catalog.name == imageEnumName or catalog.name == iconEnumName %} + public var uiImage: UIImage { UIImage(named: self.rawValue, in: {{bundleToken}}.bundle, with: nil)! } + public var image: Image { Image(self.rawValue, bundle: {{bundleToken}}.bundle) } + {% endif %} + } + {% endfor %} + {% else %} + {% call enumBlock catalogs.first.assets %} + {% endif %} +} + +{% endif %} diff --git a/Workspace.swift b/Workspace.swift new file mode 100644 index 0000000..d486dc8 --- /dev/null +++ b/Workspace.swift @@ -0,0 +1,14 @@ +// +// Workspace.swift +// Dori-iOS +// +// Created by 강동영 on 2/12/26. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let workspace = Workspace( + name: DoriManifest.projectName, + projects: DoriLayer.allCases.map(\.projectPath) +) diff --git a/docs/routing-guide.md b/docs/routing-guide.md new file mode 100644 index 0000000..07b94a6 --- /dev/null +++ b/docs/routing-guide.md @@ -0,0 +1,440 @@ +# iOS 라우팅 패턴 가이드: 바닐라 SwiftUI vs TCA + +--- + +## 1. 탭 기반 화면 전환 + +### 바닐라 SwiftUI + +```swift +struct MainTabView: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + CalendarScreen() + .tag(0) + .tabItem { Label("캘린더", systemImage: "calendar") } + + HistoryScreen() + .tag(1) + .tabItem { Label("내역", systemImage: "list.bullet") } + + MyPageScreen() + .tag(2) + .tabItem { Label("마이페이지", systemImage: "person.circle") } + } + } +} +``` + +- `@State`로 탭 인덱스를 관리한다. +- 각 탭의 화면이 독립적인 View로 구성되며, 상위에서 상태 공유가 필요하면 `@Binding` 또는 `@EnvironmentObject`를 사용한다. + +### TCA + +```swift +@Reducer +struct MainTabFeature { + @ObservableState + struct State: Equatable { + var selectedTab: Tab = .calendar + var calendar = CalendarFeature.State() + var historyList = HistoryListFeature.State() + var myPage = MyPageFeature.State() + + enum Tab: Equatable { + case calendar, history, myPage + } + } + + enum Action { + case tabSelected(State.Tab) + case calendar(CalendarFeature.Action) + case historyList(HistoryListFeature.Action) + case myPage(MyPageFeature.Action) + } + + var body: some ReducerOf { + Scope(state: \.calendar, action: \.calendar) { + CalendarFeature() + } + Scope(state: \.historyList, action: \.historyList) { + HistoryListFeature() + } + Scope(state: \.myPage, action: \.myPage) { + MyPageFeature() + } + Reduce { state, action in + switch action { + case let .tabSelected(tab): + state.selectedTab = tab + return .none + case .calendar, .historyList, .myPage: + return .none + } + } + } +} +``` + +```swift +struct MainTabView: View { + @Bindable var store: StoreOf + + var body: some View { + TabView(selection: $store.selectedTab.sending(\.tabSelected)) { + CalendarView( + store: store.scope(state: \.calendar, action: \.calendar) + ) + .tag(MainTabFeature.State.Tab.calendar) + .tabItem { Label("캘린더", systemImage: "calendar") } + + HistoryListView( + store: store.scope(state: \.historyList, action: \.historyList) + ) + .tag(MainTabFeature.State.Tab.history) + .tabItem { Label("내역", systemImage: "list.bullet") } + + MyPageView( + store: store.scope(state: \.myPage, action: \.myPage) + ) + .tag(MainTabFeature.State.Tab.myPage) + .tabItem { Label("마이페이지", systemImage: "person.circle") } + } + } +} +``` + +**핵심 차이점**: +| 항목 | 바닐라 SwiftUI | TCA | +|------|---------------|-----| +| 탭 상태 | `@State` Int | `State.Tab` enum | +| 자식 상태 관리 | 각 View 내부 | 부모 Reducer에서 `Scope` 합성 | +| 자식 간 통신 | `@Binding` / `@EnvironmentObject` | 부모 Reducer에서 Action 중계 | +| 테스트 | UI 테스트 필요 | `TestStore`로 유닛 테스트 가능 | + +--- + +## 2. Feature 간 이동 + +### 2-1. 같은 탭 내 push (Stack Navigation) + +#### 바닐라 SwiftUI + +```swift +struct HistoryListScreen: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + List(persons) { person in + Button(person.name) { + path.append(person) + } + } + .navigationDestination(for: Person.self) { person in + HistoryDetailScreen(person: person) + } + } + } +} +``` + +#### TCA + +```swift +@Reducer +struct HistoryListFeature { + @ObservableState + struct State: Equatable { + var persons: [Person] = [] + var path = StackState() + } + + enum Action { + case path(StackActionOf) + case personTapped(Person) + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .personTapped(person): + state.path.append(HistoryDetailFeature.State(person: person)) + return .none + case .path: + return .none + } + } + .forEach(\.path, action: \.path) { + HistoryDetailFeature() + } + } +} +``` + +```swift +struct HistoryListView: View { + @Bindable var store: StoreOf + + var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.persons) { person in + Button { + store.send(.personTapped(person)) + } label: { + PersonCardView(person: person) + } + } + } + } destination: { store in + HistoryDetailView(store: store) + } + } +} +``` + +**핵심 차이점**: +| 항목 | 바닐라 SwiftUI | TCA | +|------|---------------|-----| +| 네비게이션 스택 | `NavigationPath` | `StackState` | +| push 트리거 | `path.append()` 직접 호출 | Action → Reducer에서 `path.append()` | +| 자식 생명주기 | SwiftUI가 관리 | `.forEach`로 Reducer에서 관리 | +| pop 처리 | SwiftUI 자동 | StackAction으로 자동 처리 | + +### 2-2. 다른 탭으로 전환 + +#### 바닐라 SwiftUI + +```swift +// 자식 View에서 탭 전환 +struct MyPageScreen: View { + @Binding var selectedTab: Int + + var body: some View { + Button("내역 보기") { + selectedTab = 1 // History 탭으로 전환 + } + } +} +``` + +#### TCA + +```swift +// MyPageFeature에서 delegate Action을 보냄 +@Reducer +struct MyPageFeature { + enum Action { + case delegate(Delegate) + enum Delegate { + case switchToHistory + } + } +} + +// MainTabFeature에서 delegate를 처리 +Reduce { state, action in + switch action { + case .myPage(.delegate(.switchToHistory)): + state.selectedTab = .history + return .none + // ... + } +} +``` + +**핵심 차이점**: 바닐라 SwiftUI는 `@Binding`으로 직접 수정하지만, TCA는 delegate Action을 부모 Reducer에서 처리한다. TCA 패턴이 Feature 간 결합도를 낮추고 데이터 흐름을 명확하게 한다. + +### 2-3. 모달/시트 (Tree Navigation) + +#### 바닐라 SwiftUI + +```swift +struct CalendarScreen: View { + @State private var showAddTransaction = false + + var body: some View { + content + .sheet(isPresented: $showAddTransaction) { + AddTransactionScreen() + } + } +} +``` + +#### TCA + +```swift +@Reducer +struct CalendarFeature { + @Reducer + enum Destination { + case addTransaction(AddTransactionFeature) + } + + @ObservableState + struct State: Equatable { + @Presents var destination: Destination.State? + } + + enum Action { + case destination(PresentationAction) + case addButtonTapped + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .addButtonTapped: + state.destination = .addTransaction(AddTransactionFeature.State()) + return .none + case .destination: + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} +``` + +```swift +struct CalendarView: View { + @Bindable var store: StoreOf + + var body: some View { + content + .sheet( + item: $store.scope( + state: \.destination?.addTransaction, + action: \.destination.addTransaction + ) + ) { store in + AddTransactionView(store: store) + } + } +} +``` + +**핵심 차이점**: +| 항목 | 바닐라 SwiftUI | TCA | +|------|---------------|-----| +| 표시 상태 | `@State Bool` | `@Presents Destination.State?` | +| 자식 상태 | 자식 View 내부 관리 | 부모 Reducer에서 초기화 | +| dismiss | SwiftUI 자동 | `state.destination = nil` | +| 자식 결과 반환 | 콜백/`@Binding` | `PresentationAction`으로 자동 전달 | + +--- + +## 3. 딥링크 + +### 바닐라 SwiftUI + +```swift +@main +struct DoriApp: App { + @State private var selectedTab = 0 + @State private var historyPath = NavigationPath() + + var body: some Scene { + WindowGroup { + MainTabView(selectedTab: $selectedTab, historyPath: $historyPath) + .onOpenURL { url in + handleDeepLink(url) + } + } + } + + private func handleDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } + + switch components.host { + case "history": + selectedTab = 1 + if let personId = components.queryItems?.first(where: { $0.name == "personId" })?.value { + historyPath.append(UUID(uuidString: personId)!) + } + default: + break + } + } +} +``` + +### TCA + +```swift +// AppFeature 또는 MainTabFeature에 딥링크 Action 추가 +enum Action { + case deepLinkReceived(URL) +} + +// Reducer에서 URL을 파싱하여 상태를 변경 +case let .deepLinkReceived(url): + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return .none + } + switch components.host { + case "history": + state.selectedTab = .history + if let personIdString = components.queryItems?.first(where: { $0.name == "personId" })?.value, + let personId = UUID(uuidString: personIdString) { + return .send(.historyList(.navigateToPerson(personId))) + } + default: + break + } + return .none +``` + +```swift +// View에서 onOpenURL → store.send +.onOpenURL { url in + store.send(.deepLinkReceived(url)) +} +``` + +**핵심 차이점**: +| 항목 | 바닐라 SwiftUI | TCA | +|------|---------------|-----| +| URL 파싱 위치 | View/App 레벨 | Reducer | +| 상태 변경 | `@State`/`@Binding` 직접 수정 | Action → Reducer → State | +| 테스트 | 어려움 | `store.send(.deepLinkReceived(url))`로 쉽게 테스트 | + +### URL 스킴 설계 예시 + +``` +dori://history → 내역 탭 이동 +dori://history?personId={uuid} → 특정 인물 상세 +dori://calendar?month=2026-03 → 특정 월 캘린더 +``` + +--- + +## Dori 프로젝트 Navigation 맵 + +``` +AppFeature (struct @Reducer, enum State) +├── IntroFeature +│ └── delegate(.loginSucceeded) → 부모가 .mainTab으로 전환 +└── MainTabFeature (Tab: Scope 합성) + ├── CalendarFeature + │ └── (Tree: 거래 등록 모달 — @Presents Destination) + ├── HistoryListFeature (Stack: StackState) + │ └── HistoryDetailFeature + │ └── (Tree: 수정 모달, 삭제 Alert) + └── MyPageFeature + └── (Tree: 로그아웃/탈퇴 Alert) +``` + +### 패턴 선택 기준 + +| 시나리오 | 패턴 | 이유 | +|---------|------|------| +| Intro → MainTab 분기 | struct Reducer + enum State | 로그인 상태에 따른 전체 화면 교체 | +| 탭 전환 | Scope 합성 | 각 탭의 자식 Feature를 독립적으로 관리 | +| 리스트 → 상세 push | Stack (StackState) | 동적 깊이의 navigation stack | +| 모달/시트/Alert | Tree (@Presents) | 1:1 부모-자식 관계의 일시적 표시 | +| Feature 간 통신 | delegate Action | Feature 간 직접 참조 없이 부모를 통해 중계 |