diff --git a/.gitignore b/.gitignore index 8e78a2e..1c6737d 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,9 @@ iOSInjectionProject/ # Tuist derived files graph.dot Derived/ +/Picke-*.png +docs/graph/ +.tuist-spider/ # Tuist managed dependencies **/Tuist/.build diff --git a/AGENTS.md b/AGENTS.md index 869a2ae..7341fc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,74 @@ tuist graph --format pdf --path ./graph.pdf - `Release.xcconfig` — 릴리즈 빌드 공통 - `Shared.xcconfig` — 모든 환경 공통 설정 +## 🎨 디자인 시스템 & 토큰 워크플로우 + +### 디자인 토큰 코드젠 (`Tools/TokenGenerator.swift`) + +Tokens Studio for Figma 가 export 한 `Mode 1.tokens.json`을 Swift 토큰으로 변환합니다. + +**단일 소스** +- 토큰 JSON 은 `SWYP-Find/design-tokens` 레포(public)가 단일 소스 +- Picke-iOS 의 `Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json` 은 워크플로우 실행 시에만 다운로드되는 임시 파일이며 git 에 추적되지 않음 (`.gitignore` 처리) + +**자동 생성 출력 (⚠️ 직접 수정 금지 — 헤더에 AUTO-GENERATED 마크)** +- `Sources/Color/ShapeStyle+.swift` — 색 토큰 (`.primary500`, `.bgDefault`, `.borderError` 등) +- `Sources/Extension/CGFloat/CGFloat+Radius+.swift` — radius (`.none` / `.default` / `.full`) +- `Sources/Extension/CGFloat/CGFloat+Spacing+.swift` — spacing (`.s0` ~ `.s96`) +- `Sources/UI/Token/ComponentToken.swift` — 컴포넌트 토큰 (`ComponentToken.Button.Primary.Background.default` 등) + +**디자이너 핸드오프 흐름 (자동)** +1. 디자이너가 Tokens Studio → `Mode 1.tokens.json` export +2. `SWYP-Find/design-tokens` 의 `main` 브랜치에 push +3. (자동) `notify-ios.yml` → `repository_dispatch(design-tokens-updated)` 발사 +4. (자동) Picke-iOS `sync-design-tokens.yml` 실행 → raw URL 로 JSON 다운로드 → `swift Tools/TokenGenerator.swift` → 4개 출력 파일을 `develop` 에 직접 commit + push + +수동 트리거가 필요할 때: +```bash +gh workflow run sync-design-tokens.yml --repo SWYP-Find/Picke-iOS +``` + +**Component 토큰 해석 우선순위** (TokenGenerator 내부) +1. `"{Colors.brand.primary.500}"` 같은 string alias → `.primary500` +2. inline hex + `$extensions.com.figma.aliasData.targetVariableName` → 해당 변수명이 우리 토큰셋에 있으면 그쪽으로 +3. inline hex가 brand/semantic 변수의 hex와 일치하면 그 변수로 +4. 위 셋 다 실패 시 `.init(hex: "...")` inline + +### DesignSystem 폴더 구조 + +``` +Projects/Shared/DesignSystem/Sources/ +├── Color/ # 색 토큰 (auto) +├── CustomFont/ # Pretendard 폰트 정의 +├── Image/ # ImageAsset +├── Extension/ +│ ├── CGFloat/ # radius / spacing (auto) +│ ├── Color/ # Color/UIColor hex 초기화 등 +│ ├── Image/ +│ └── ScreenSize/ +└── UI/ + ├── Button/ # CTA 버튼 컴포넌트 + ├── Navigaion/ # UINavigationController gesture 확장 + └── Token/ # 컴포넌트 토큰 (auto) +``` + +### UI 컴포넌트 작성 규칙 + +- **색·radius는 `ComponentToken.*` 또는 brand/semantic 토큰 참조**. hex 리터럴(`.init(hex: "...")`) 직접 사용 금지 +- **CTA 버튼은 두 API 제공 (병행 유지):** + - `CustomButton(action:title:config:isEnable:trailingIcon:)` — Config 기반, 기존 호출처 호환 + - `Button { } .ctaButtonStyle(.primary, size: .large, icon: nil)` — `ButtonStyle` 기반 +- variant × size 확장 시 `CTAButtonStyle.swift`의 enum에 케이스 추가 → `ComponentToken.Button.*`을 통해 색 분기 +- pressed 상태는 `configuration.isPressed`로 토큰의 `.Background.pressed` 색을 사용 (opacity 변경 X) + +### 새 파일 추가 시 Tuist 재생성 필수 + +`Project.swift`의 `sources: ["Sources/**"]` glob이 새 파일을 자동 픽업하지만, xcodeproj 동기화는 별도: +```bash +tuist generate --no-open --path Projects/Shared/DesignSystem +``` +재생성 전 SourceKit 에러가 떠도 실제 빌드는 정상일 수 있으니, **항상 `xcodebuild`로 실 빌드 확인**할 것. + ## 📊 지원 스킬 목록 ### TDD 자동화 스킬 diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index b620171..9ffbbd1 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -25,6 +25,7 @@ public extension ModulePath { public static let name: String = "Presentation" + case Auth } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift index df13f82..af96f40 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift @@ -10,40 +10,44 @@ import ProjectDescription /// 🎯 프로젝트 설정을 한 곳에서 관리합니다 /// 여기서 프로젝트 이름을 바꾸면 모든 곳에 자동으로 적용됩니다! -public struct ProjectConfig { - - // MARK: - 🎯 프로젝트 이름 설정 (여기만 바꾸면 됩니다!) - /// 프로젝트 이름을 여기서 설정하세요 - public static let projectName: String = "Picke" - - // MARK: - 📱 앱 정보 (자동 생성됨) - public static let appName = projectName - public static let appDisplayName = projectName // 🎯 앱 화면에 표시될 이름 - public static let appStageName = "\(projectName)-Stage" - public static let appProdName = "\(projectName)-Prod" - public static let appDevName = "\(projectName)-Dev" - - // MARK: - 🔧 기타 설정 - public static let bundleIdPrefix = "io.Picke.co" - public static let teamId = "N94CS4N6VR" - public static let deploymentTarget: ProjectDescription.DeploymentTargets = .iOS("17.0") - public static let deploymentDestination: ProjectDescription.Destinations = [.iPhone] - public static let appVersion = "1.0.0" - - // MARK: - 🎨 테마 설정 (필요시 수정) - public static let organizationName = "Roy" - public static let description = "🎵 Multi-module application template" +public enum ProjectConfig { + // MARK: - 🎯 프로젝트 이름 설정 (여기만 바꾸면 됩니다!) + + /// 프로젝트 이름을 여기서 설정하세요 + public static let projectName: String = "Picke" + + // MARK: - 📱 앱 정보 (자동 생성됨) + + public static let appName = projectName + public static let appDisplayName = projectName // 🎯 앱 화면에 표시될 이름 + public static let appStageName = "\(projectName)-Stage" + public static let appProdName = "\(projectName)-Prod" + public static let appDevName = "\(projectName)-Dev" + + // MARK: - 🔧 기타 설정 + + public static let bundleIdPrefix = "io.Picke.co" + public static let teamId = "N94CS4N6VR" + public static let deploymentTarget: ProjectDescription.DeploymentTargets = .iOS("17.0") + public static let deploymentDestination: ProjectDescription.Destinations = [.iPhone] + public static let appVersion = "1.0.0" + + // MARK: - 🎨 테마 설정 (필요시 수정) + + public static let organizationName = "Roy" + public static let description = "🎵 Multi-module application template" } // MARK: - 🛠 Helper Extensions + public extension ProjectConfig { - /// 워크스페이스 이름 (프로젝트 이름과 동일) - static var workspaceName: String { - return projectName - } - - /// 메인 번들 ID - static var mainBundleId: String { - return bundleIdPrefix - } + /// 워크스페이스 이름 (프로젝트 이름과 동일) + static var workspaceName: String { + projectName + } + + /// 메인 번들 ID + static var mainBundleId: String { + bundleIdPrefix + } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift index 771644c..421fb37 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift @@ -12,45 +12,45 @@ public typealias InfoPlistDictionary = [String: Plist.Value] extension InfoPlistDictionary { func setUIUserInterfaceStyle(_ value: String) -> InfoPlistDictionary { - return self.merging(["UIUserInterfaceStyle": .string(value)]) { (_, new) in new } + merging(["UIUserInterfaceStyle": .string(value)]) { _, new in new } } - + func setCFBundleDevelopmentRegion(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleDevelopmentRegion": .string(value)]) { (_, new) in new } + merging(["CFBundleDevelopmentRegion": .string(value)]) { _, new in new } } - + func setCFBundleExecutable(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleExecutable": .string(value)]) { (_, new) in new } + merging(["CFBundleExecutable": .string(value)]) { _, new in new } } func setCFBundleIdentifier(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleIdentifier": .string(value)]) { (_, new) in new } + merging(["CFBundleIdentifier": .string(value)]) { _, new in new } } - + func setCFBundleInfoDictionaryVersion(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleInfoDictionaryVersion": .string(value)]) { (_, new) in new } + merging(["CFBundleInfoDictionaryVersion": .string(value)]) { _, new in new } } - + func setCFBundleName(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleName": .string(value)]) { (_, new) in new } + merging(["CFBundleName": .string(value)]) { _, new in new } } func setCFBundleDisplayName(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleDisplayName": .string(value)]) { (_, new) in new } + merging(["CFBundleDisplayName": .string(value)]) { _, new in new } } func setCFBundlePackageType(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundlePackageType": .string(value)]) { (_, new) in new } + merging(["CFBundlePackageType": .string(value)]) { _, new in new } } - + func setCFBundleShortVersionString(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleShortVersionString": .string(value)]) { (_, new) in new } + merging(["CFBundleShortVersionString": .string(value)]) { _, new in new } } - + // 매개변수 없는 경우, 기본 지역을 "ko"로 설정 func setCFBundleDevelopmentRegion() -> InfoPlistDictionary { - return self.merging(["CFBundleDevelopmentRegion": .string("ko")]) { - (_, new) in new + merging(["CFBundleDevelopmentRegion": .string("ko")]) { + _, new in new } } @@ -58,160 +58,168 @@ extension InfoPlistDictionary { func convertToPlistValue(_ value: Any) -> Plist.Value { switch value { case let string as String: - return .string(string) + .string(string) case let array as [Any]: - return .array(array.map { convertToPlistValue($0) }) + .array(array.map { convertToPlistValue($0) }) case let dictionary as [String: Any]: - return .dictionary(dictionary.mapValues { convertToPlistValue($0) }) + .dictionary(dictionary.mapValues { convertToPlistValue($0) }) case let bool as Bool: - return .boolean(bool) + .boolean(bool) default: - return .string("\(value)") + .string("\(value)") } } let dict: [String: Plist.Value] = [ - "CFBundleURLTypes": .array(value.map { .dictionary($0.mapValues { convertToPlistValue($0) }) }) + "CFBundleURLTypes": .array(value.map { .dictionary($0.mapValues { convertToPlistValue($0) }) }), ] - return self.merging(dict) { (_, new) in new } + return merging(dict) { _, new in new } } - + func setCFBundleVersion(_ value: String) -> InfoPlistDictionary { - return self.merging(["CFBundleVersion": .string(value)]) { (_, new) in new } + merging(["CFBundleVersion": .string(value)]) { _, new in new } } - + func setGIDClientID(_ value: String) -> InfoPlistDictionary { - return self.merging(["GIDClientID": .string(value)]) { (_, new) in new } + merging(["GIDClientID": .string(value)]) { _, new in new } } - + func setLSRequiresIPhoneOS(_ value: Bool) -> InfoPlistDictionary { - return self.merging(["LSRequiresIPhoneOS": .boolean(value)]) { (_, new) in new } + merging(["LSRequiresIPhoneOS": .boolean(value)]) { _, new in new } } - + func setUIAppFonts(_ value: [String]) -> InfoPlistDictionary { - return self.merging(["UIAppFonts": .array(value.map { .string($0) })]) { (_, new) in new } + merging(["UIAppFonts": .array(value.map { .string($0) })]) { _, new in new } } - + func setAppTransportSecurity() -> InfoPlistDictionary { let dict: [String: Plist.Value] = [ "NSAppTransportSecurity": .dictionary([ - "NSAllowsArbitraryLoads": .boolean(true) - ]) + "NSAllowsArbitraryLoads": .boolean(true), + ]), ] - return self.merging(dict) { (_, new) in new } + return merging(dict) { _, new in new } } - // 매개변수 없는 URL 타입 (예: 카카오) + // 매개변수 없는 URL 타입 (Google REVERSED_CLIENT_ID) func setCFBundleURLTypes() -> InfoPlistDictionary { let dict: [String: Plist.Value] = [ "CFBundleURLTypes": .array([ .dictionary([ "CFBundleURLSchemes": .array([ - .string("${REVERSED_CLIENT_ID}") -// .string("com.googleusercontent.apps.882277748169-glpolfiecue4lqqps6hmgj9t8lm1g5qp") - ]) - ]) - ]) + .string("${REVERSED_CLIENT_ID}"), + ]), + ]), + ]), ] - return self.merging(dict) { (_, new) in new } + return merging(dict) { _, new in new } + } + + func setLSApplicationQueriesSchemes(_ value: [String]) -> InfoPlistDictionary { + merging([ + "LSApplicationQueriesSchemes": .array(value.map { .string($0) }), + ]) { _, new in new } + } + + func setKakaoRestApiKey(_ value: String = "$(KAKAO_REST_API_KEY)") -> InfoPlistDictionary { + merging(["KAKAO_REST_API_KEY": .string(value)]) { _, new in new } } - + func setUIApplicationSceneManifest(_ value: [String: Any]) -> InfoPlistDictionary { func convertToPlistValue(_ value: Any) -> Plist.Value { switch value { case let string as String: - return .string(string) + .string(string) case let array as [Any]: - return .array(array.map { convertToPlistValue($0) }) + .array(array.map { convertToPlistValue($0) }) case let dictionary as [String: Any]: - return .dictionary(dictionary.mapValues { convertToPlistValue($0) }) + .dictionary(dictionary.mapValues { convertToPlistValue($0) }) case let bool as Bool: - return .boolean(bool) + .boolean(bool) default: - return .string("\(value)") + .string("\(value)") } } let dict: [String: Plist.Value] = [ - "UIApplicationSceneManifest": convertToPlistValue(value) + "UIApplicationSceneManifest": convertToPlistValue(value), ] - return self.merging(dict) { (_, new) in new } + return merging(dict) { _, new in new } } - + func setUILaunchStoryboardName(_ value: String) -> InfoPlistDictionary { - return self.merging(["UILaunchStoryboardName": .string(value)]) { (_, new) in new } + merging(["UILaunchStoryboardName": .string(value)]) { _, new in new } } - + func setUIRequiredDeviceCapabilities(_ value: [String]) -> InfoPlistDictionary { - return self.merging(["UIRequiredDeviceCapabilities": .array(value.map { .string($0) })]) { (_, new) in new } + merging(["UIRequiredDeviceCapabilities": .array(value.map { .string($0) })]) { _, new in new } } - + func setUISupportedInterfaceOrientations(_ value: [String]) -> InfoPlistDictionary { - return self.merging(["UISupportedInterfaceOrientations": .array(value.map { .string($0) })]) { (_, new) in new } + merging(["UISupportedInterfaceOrientations": .array(value.map { .string($0) })]) { _, new in new } } - + func setNSCameraUsageDescription(_ value: String) -> InfoPlistDictionary { - return self.merging(["NSCameraUsageDescription": .string(value)]) { (_, new) in new } + merging(["NSCameraUsageDescription": .string(value)]) { _, new in new } } - + func setUILaunchScreens() -> InfoPlistDictionary { let dict: InfoPlistDictionary = [ "UILaunchScreen": .dictionary([ "UIColorName": .string(""), - "UIImageName": .string("") - ]) + "UIImageName": .string(""), + ]), ] - return self.merging(dict) { _, new in new } + return merging(dict) { _, new in new } } - + func setAppUseExemptEncryption(value: Bool) -> InfoPlistDictionary { - return self.merging(["ITSAppUsesNonExemptEncryption": .boolean(value)]) { (_, new) in new } + merging(["ITSAppUsesNonExemptEncryption": .boolean(value)]) { _, new in new } } - + func setFirebaseAnalyticsCollectionEnabled() -> InfoPlistDictionary { - return self.merging(["FIREBASE_ANALYTICS_COLLECTION_ENABLED": .boolean(false)]) { (_, new) in new } + merging(["FIREBASE_ANALYTICS_COLLECTION_ENABLED": .boolean(false)]) { _, new in new } } - + func setCalenderUsage(_ description: String) -> InfoPlistDictionary { - return self.merging(["NSCalendarsUsageDescription": .string(description)]) { (_, new) in new } + merging(["NSCalendarsUsageDescription": .string(description)]) { _, new in new } } - + func setGoogleReversedClientID(_ value: String) -> InfoPlistDictionary { - return self.merging(["REVERSED_CLIENT_ID": .string(value)]) { (_, new) in new } + merging(["REVERSED_CLIENT_ID": .string(value)]) { _, new in new } } - + func setGoogleClientID(_ value: String) -> InfoPlistDictionary { - return self.merging(["GOOGLE_CLIENT_ID": .string(value)]) { (_, new) in new } + merging(["GOOGLE_CLIENT_ID": .string(value)]) { _, new in new } } - + func setGoogleClientiOSID(_ value: String) -> InfoPlistDictionary { - return self.merging(["GOOGLE_IOS_CLIENT_ID": .string(value)]) { (_, new) in new } + merging(["GOOGLE_IOS_CLIENT_ID": .string(value)]) { _, new in new } } - + func setMixpanelToken(_ value: String) -> InfoPlistDictionary { - return self.merging(["MIXPANEL_TOKEN": .string(value)]) { (_, new) in new } + merging(["MIXPANEL_TOKEN": .string(value)]) { _, new in new } } - func setBaseURL(_ value: String) -> InfoPlistDictionary { - return self.merging(["BASE_URL": .string(value)]) { (_, new) in new } + merging(["BASE_URL": .string(value)]) { _, new in new } } - + func setAdmobToken(_ value: String) -> InfoPlistDictionary { - return self.merging(["ADMOB_TOKEN": .string(value)]) { (_, new) in new } + merging(["ADMOB_TOKEN": .string(value)]) { _, new in new } } - + func setGADApplicationId(_ value: String) -> InfoPlistDictionary { - return self.merging(["GADApplicationIdentifier": .string(value)]) { (_, new) in new } + merging(["GADApplicationIdentifier": .string(value)]) { _, new in new } } - + func setSKAdNetworkItems(_ identifiers: [String]) -> InfoPlistDictionary { - return self.merging([ + merging([ "SKAdNetworkItems": .array( identifiers.map { .dictionary([ - "SKAdNetworkIdentifier": .string($0) + "SKAdNetworkIdentifier": .string($0), ]) } - ) - ]) { (_, new) in new } + ), + ]) { _, new in new } } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index 6376cd3..2092c15 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -19,7 +19,7 @@ public extension InfoPlist { .setCFBundleIdentifier("$(PRODUCT_BUNDLE_IDENTIFIER)") .setCFBundleInfoDictionaryVersion("6.0") .setCFBundleName("$(PRODUCT_NAME)") - .setCFBundleDisplayName("$(BUNDLE_DISPLAY_NAME)") // 🎯 xconfig에서 설정 + .setCFBundleDisplayName("$(BUNDLE_DISPLAY_NAME)") // 🎯 xconfig에서 설정 .setCFBundlePackageType("APPL") .setCFBundleShortVersionString(.appVersion()) .setAppTransportSecurity() @@ -34,9 +34,9 @@ public extension InfoPlist { "UIWindowSceneSessionRoleApplication": [ [ "UISceneConfigurationName": "Default Configuration", - ] - ] - ] + ], + ], + ], ]) .setUIRequiredDeviceCapabilities(["armv7"]) .setCFBundleDevelopmentRegion() @@ -48,10 +48,14 @@ public extension InfoPlist { .setGIDClientID("${GOOGLE_CLIENT_ID}") .setAdmobToken("${ADMOB_TOKEN}") .setGADApplicationId("${ADMOB_TOKEN}") + .setKakaoRestApiKey() + .setLSApplicationQueriesSchemes([ + "kakaokompassauth", // 카카오톡 로그인 + "kakaolink", // 카카오톡 공유 + ]) // .setSKAdNetworkItems([ // // ]) - ) static let moduleInfoPlist: Self = .extendingDefault( diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 24272bf..0668f4e 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -12,6 +12,7 @@ let project = Project.makeAppModule( dependencies: [ .Presentation(implements: .Presentation), .Data(implements: .Repository), + .Shared(implements: .Shared), .SPM.googleMobileAds, .SPM.firebaseCrashlytics, .SPM.mixpanel, diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024 1.png new file mode 100644 index 0000000..774c0ac Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024 1.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024 2.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024 2.png new file mode 100644 index 0000000..774c0ac Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024 2.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..774c0ac Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..7acedf9 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "1024 2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "1024 1.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Projects/App/Resources/splashLogo.gif b/Projects/App/Resources/splashLogo.gif new file mode 100644 index 0000000..7652f31 Binary files /dev/null and b/Projects/App/Resources/splashLogo.gif differ diff --git a/Projects/App/Sources/ContentView.swift b/Projects/App/Sources/ContentView.swift index ed8b030..97a8c48 100644 --- a/Projects/App/Sources/ContentView.swift +++ b/Projects/App/Sources/ContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Presentation public struct ContentView: View { public init() {} @@ -13,3 +14,9 @@ public struct ContentView: View { #Preview { ContentView() } + +#Preview { + AuthCoordinatorView(store: .init(initialState: AuthCoordinator.State(), reducer: { + AuthCoordinator() + })) +} diff --git a/Projects/App/Sources/Di/AppPresentationContextProvider.swift b/Projects/App/Sources/Di/AppPresentationContextProvider.swift new file mode 100644 index 0000000..3d14bbd --- /dev/null +++ b/Projects/App/Sources/Di/AppPresentationContextProvider.swift @@ -0,0 +1,24 @@ +// +// AppPresentationContextProvider.swift +// Picke +// +// Created by Wonji Suh on 5/14/26. +// + +import AuthenticationServices +import UIKit + +/// 앱 전체에서 사용할 ASWebAuthenticationSession용 presentation provider +final class AppPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { $0.isKeyWindow }) ?? + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first? + .windows.first ?? + ASPresentationAnchor() + } +} diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index cb7054a..3e879f6 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -8,8 +8,8 @@ import Foundation import DomainInterface -import Repository import Foundations +import Repository import UseCase import ComposableArchitecture @@ -19,40 +19,49 @@ import WeaveDI @MainActor public final class AppDIManager: Sendable { public static let shared = AppDIManager() - + private init() {} - + /// 기본 WeaveDI 의존성 등록 (Repository만) public func registerDefaultDependencies() { // Repository 구현체들만 등록 WeaveDI.builder - // 🔧 인프라 계층 (PFW 단순성 원칙) + // 🔧 인프라 계층 (PFW 단순성 원칙) .register { KeychainManager() as KeychainManaging } .register { let keychainManager = UnifiedDI.resolve(KeychainManaging.self) ?? KeychainManager() return KeychainTokenProvider(keychainManager: keychainManager) as TokenProviding } - - // 🏗️ Repository 계층 (Clean Architecture + PFW) -// .register { AuthRepositoryImpl() as AuthInterface } + + // 🏗️ Repository 계층 (Clean Architecture + PFW) + .register { AuthRepositoryImpl() as AuthInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } - - // 🔐 OAuth Provider 계층 (PFW 조합 패턴) -// .register { GoogleOAuthRepositoryImpl() as GoogleOAuthInterface } -// .register { AppleLoginRepositoryImpl() as AppleAuthRequestInterface } -// .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } -// .register { AppleOAuthProvider() as AppleOAuthProviderInterface } -// .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface } - - // 📝 비즈니스 로직 계층 (PFW 단일 책임) + + // 🔐 OAuth Provider 계층 (PFW 조합 패턴) + .register { + MainActor.assumeIsolated { + GoogleOAuthRepositoryImpl(presentationContextProvider: AppPresentationContextProvider( + )) as GoogleOAuthInterface + } + } + .register { AppleLoginRepositoryImpl() as AppleAuthRequestInterface } + .register { + MainActor.assumeIsolated { + KakaoOAuthRepository(presentationContextProvider: AppPresentationContextProvider()) as KakaoOAuthInterface + } + } + .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } + .register { AppleOAuthProvider() as AppleOAuthProviderInterface } + .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface } + .register { KakaoOAuthProvider() as KakaoOAuthProviderInterface } + // 📝 비즈니스 로직 계층 (PFW 단일 책임) // .register { OnBoardingRepositoryImpl() as OnBoardingInterface } // .register { SignUpRepositoryImpl() as SignUpInterface } // .register { AttendanceRepositoryImpl() as AttendanceInterface } // .register { MyPageRepositoryImpl() as MyPageRepositoryInterface } // .register { ScheduleRepositoryImpl() as ScheduleInterface } // .register { QRCodeRepositoryImpl() as QRCodeInterface } - .configure() } } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 9f77c9a..2907501 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -5,10 +5,10 @@ // Created by Wonji Suh on 5/6/26. // -import Splash import ComposableArchitecture import Entity import LogMacro +import Presentation @Reducer public struct AppReducer: Sendable { @@ -17,6 +17,7 @@ public struct AppReducer: Sendable { @ObservableState public enum State { case splash(SplashFeature.State) + case auth(AuthCoordinator.State) public init() { @@ -27,7 +28,7 @@ public struct AppReducer: Sendable { var animationID: String { switch self { case .splash: return "splash" -// case .auth: return "auth" + case .auth: return "auth" } } } @@ -71,6 +72,7 @@ public struct AppReducer: Sendable { @CasePathable public enum ScopeAction { case splash(SplashFeature.Action) + case auth(AuthCoordinator.Action) } @@ -137,7 +139,9 @@ public struct AppReducer: Sendable { .ifCaseLet(\.splash, action: \.scope.splash) { SplashFeature() } - + .ifCaseLet(\.auth, action: \.scope.auth) { + AuthCoordinator() + } } private func handleViewAction( @@ -147,7 +151,7 @@ public struct AppReducer: Sendable { switch action { case .presentView: return .run { send in -// await send(.scope(.splash(.view(.onAppear)))) + await send(.scope(.splash(.view(.onAppear)))) } case .presentRoot: @@ -180,7 +184,7 @@ public struct AppReducer: Sendable { ) -> Effect { switch action { case .completeAuthTransition: -// state = .auth(.init()) + state = .auth(.init()) return .none case .completeStaffTransition: @@ -214,6 +218,17 @@ public struct AppReducer: Sendable { state: inout State, action: ScopeAction ) -> Effect { + switch action { + case .splash(.view(.onAppear)): + return .run { send in + try await clock.sleep(for: .seconds(3)) + try await send(.view(.presentAuth)) + } + + default: + return .none + } + // 🎯 PFW 철학: 타입 안전한 상태 매칭 // switch (action, state) { // case (.staff, .staff), (.member, .member), @@ -228,7 +243,6 @@ public struct AppReducer: Sendable { // 🎯 PFW 패턴: 단순한 네비게이션 처리 // return handleScopeNavigation(action: action) - return .none } // 🎯 PFW 패턴: 네비게이션 로직 분리 diff --git a/Projects/App/Sources/View/AppView.swift b/Projects/App/Sources/View/AppView.swift index cb1f8b4..cc1d8de 100644 --- a/Projects/App/Sources/View/AppView.swift +++ b/Projects/App/Sources/View/AppView.swift @@ -7,16 +7,17 @@ import SwiftUI -import Splash import ComposableArchitecture import DesignSystem +import Presentation + struct AppView: View { @Bindable var store: StoreOf var body: some View { ZStack(alignment: .topLeading) { - Color.gray50 + Color.primary50 .edgesIgnoringSafeArea(.all) SwitchStore(store) { state in @@ -28,6 +29,15 @@ struct AppView: View { } + case .auth: + if let store = store.scope(state: \.auth, action: \.scope.auth) { + AuthCoordinatorView(store: store) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + + } } } } diff --git a/Projects/Data/API/Sources/Auth/AuthApI.swift b/Projects/Data/API/Sources/Auth/AuthApI.swift new file mode 100644 index 0000000..1a84a86 --- /dev/null +++ b/Projects/Data/API/Sources/Auth/AuthApI.swift @@ -0,0 +1,28 @@ +// +// AuthApI.swift +// API +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public enum AuthAPI: String, CaseIterable { + case login + case refresh + case withDraw + case logout + + public var description: String { + switch self { + case .login: + return "login" + case .refresh: + return "refresh" + case .withDraw: + return "" + case .logout: + return "logout" + } + } +} diff --git a/Projects/Data/API/Sources/Base.swift b/Projects/Data/API/Sources/Base.swift deleted file mode 100644 index fc5212e..0000000 --- a/Projects/Data/API/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Data/API/Sources/Base/BaseAPI.swift b/Projects/Data/API/Sources/Base/BaseAPI.swift new file mode 100644 index 0000000..3b0b7ce --- /dev/null +++ b/Projects/Data/API/Sources/Base/BaseAPI.swift @@ -0,0 +1,19 @@ +// +// BaseAPI.swift +// API +// +// Created by Wonji Suh on 4/8/25. +// + +import Foundation + +public enum BaseAPI : String { + case base + + public var apiDescription: String { + switch self { + case .base: + return "https://\(Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String ?? "")" + } + } +} diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift new file mode 100644 index 0000000..b2487d4 --- /dev/null +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -0,0 +1,32 @@ +// +// PieckeDomain.swift +// API +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +import AsyncMoya + +public enum PieckeDomain { + case auth + case profile + +} + +extension PieckeDomain: DomainType { + public var baseURLString: String { + return BaseAPI.base.apiDescription + } + + public var url: String { + switch self { + case .auth: + return "api/v1/auth/" + case .profile: + return "api/v1/me/" + + } + } +} diff --git a/Projects/Data/Model/Sources/Auth/Login/DTO/LoginDataDTO.swift b/Projects/Data/Model/Sources/Auth/Login/DTO/LoginDataDTO.swift new file mode 100644 index 0000000..4e193cd --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Login/DTO/LoginDataDTO.swift @@ -0,0 +1,27 @@ +// +// LoginDataDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct LoginDataDTO: Decodable { + public let accessToken: String + public let refreshToken: String + public let userTag: String + public let status: String + public let newUser: Bool + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case userTag = "user_tag" + case status + case newUser = "new_user" + } +} + +/// `/api/v1/auth/login/{provider}` 응답 타입 별칭 +public typealias LoginResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Auth/Login/Mapper/LoginDataDTO+.swift b/Projects/Data/Model/Sources/Auth/Login/Mapper/LoginDataDTO+.swift new file mode 100644 index 0000000..8ec2254 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Login/Mapper/LoginDataDTO+.swift @@ -0,0 +1,26 @@ +// +// LoginDataDTO+.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation + +public extension LoginDataDTO { + func toDomain(provider: SocialType) -> LoginEntity { + let token = AuthTokens( + accessToken: accessToken, + refreshToken: refreshToken + ) + return LoginEntity( + name: "", + isNewUser: newUser, + provider: provider, + token: token, + userTag: userTag, + status: status + ) + } +} diff --git a/Projects/Data/Model/Sources/Auth/Logout/DTO/LogOutDTO.swift b/Projects/Data/Model/Sources/Auth/Logout/DTO/LogOutDTO.swift new file mode 100644 index 0000000..e8a612c --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Logout/DTO/LogOutDTO.swift @@ -0,0 +1,36 @@ +// +// LogOutDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct LogoutDataDTO: Decodable, Equatable { + public let loggedOut: Bool + + public init(loggedOut: Bool) { + self.loggedOut = loggedOut + } + + private enum CodingKeys: String, CodingKey { + case loggedOut = "logged_out" + } +} + +public struct LogOutDTO: Decodable { + public let statusCode: Int + public let data: LogoutDataDTO? + public let error: APIErrorDTO? + + public init( + statusCode: Int, + data: LogoutDataDTO? = nil, + error: APIErrorDTO? = nil + ) { + self.statusCode = statusCode + self.data = data + self.error = error + } +} diff --git a/Projects/Data/Model/Sources/Auth/Logout/Mapper/LogOutDTO+.swift b/Projects/Data/Model/Sources/Auth/Logout/Mapper/LogOutDTO+.swift new file mode 100644 index 0000000..66bfa19 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Logout/Mapper/LogOutDTO+.swift @@ -0,0 +1,19 @@ +// +// LogOutDTO+.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation + +public extension LogOutDTO { + func toDomain() -> AuthExitEntity { + AuthExitEntity( + loggedOut: data?.loggedOut ?? false, + code: error?.code, + message: error?.message + ) + } +} diff --git a/Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift b/Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift new file mode 100644 index 0000000..e1e4a1d --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift @@ -0,0 +1,21 @@ +// +// TokenDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct TokenDTO: Decodable { + public let accessToken: String + public let refreshToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + } +} + +/// `/api/v1/auth/refresh` 응답 타입 별칭 +public typealias RefreshResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift b/Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift new file mode 100644 index 0000000..2d305bc --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift @@ -0,0 +1,18 @@ +// +// TokenDTO+.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation + +public extension TokenDTO { + func toDomain() -> AuthTokens { + AuthTokens( + accessToken: accessToken, + refreshToken: refreshToken + ) + } +} diff --git a/Projects/Data/Model/Sources/Auth/Withdraw/DTO/WithdrawDTO.swift b/Projects/Data/Model/Sources/Auth/Withdraw/DTO/WithdrawDTO.swift new file mode 100644 index 0000000..3643a81 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Withdraw/DTO/WithdrawDTO.swift @@ -0,0 +1,32 @@ +// +// WithdrawDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct WithdrawDataDTO: Decodable, Equatable { + public let withdrawn: Bool + + public init(withdrawn: Bool) { + self.withdrawn = withdrawn + } +} + +public struct WithdrawDTO: Decodable { + public let statusCode: Int + public let data: WithdrawDataDTO? + public let error: APIErrorDTO? + + public init( + statusCode: Int, + data: WithdrawDataDTO? = nil, + error: APIErrorDTO? = nil + ) { + self.statusCode = statusCode + self.data = data + self.error = error + } +} diff --git a/Projects/Data/Model/Sources/Auth/Withdraw/Mapper/WithdrawDTO+.swift b/Projects/Data/Model/Sources/Auth/Withdraw/Mapper/WithdrawDTO+.swift new file mode 100644 index 0000000..8ef4174 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Withdraw/Mapper/WithdrawDTO+.swift @@ -0,0 +1,22 @@ +// +// WithdrawDTO+.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation + +public extension WithdrawDTO { + func toDomain(isSuccess: Bool) -> WithdrawEntity { + let withdrawn = data?.withdrawn ?? isSuccess + + return WithdrawEntity( + isSuccess: withdrawn, + withdrawn: withdrawn, + code: error?.code, + message: error?.message + ) + } +} diff --git a/Projects/Data/Model/Sources/Common/APIErrorDTO.swift b/Projects/Data/Model/Sources/Common/APIErrorDTO.swift new file mode 100644 index 0000000..b2d15ae --- /dev/null +++ b/Projects/Data/Model/Sources/Common/APIErrorDTO.swift @@ -0,0 +1,28 @@ +// +// APIErrorDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +/// 서버 공통 에러 응답 +/// ```json +/// "error": { +/// "code": "string", +/// "message": "string" +/// } +/// ``` +public struct APIErrorDTO: Decodable, Equatable { + public let code: String + public let message: String + + public init( + code: String, + message: String + ) { + self.code = code + self.message = message + } +} diff --git a/Projects/Data/Model/Sources/Common/BaseDataDTO.swift b/Projects/Data/Model/Sources/Common/BaseDataDTO.swift new file mode 100644 index 0000000..ffcda19 --- /dev/null +++ b/Projects/Data/Model/Sources/Common/BaseDataDTO.swift @@ -0,0 +1,12 @@ +// +// BaseDataDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +/// `BaseResponseDTO` 의 `data` 필드에 들어갈 페이로드가 공통으로 채택하는 마커 프로토콜. +/// API 별 데이터 DTO 는 이 프로토콜을 채택해 `BaseResponseDTO` 와 안전하게 결합한다. +public protocol BaseDataDTO: Decodable, Equatable {} diff --git a/Projects/Data/Model/Sources/Common/BaseResponseDTO.swift b/Projects/Data/Model/Sources/Common/BaseResponseDTO.swift new file mode 100644 index 0000000..81bc8b3 --- /dev/null +++ b/Projects/Data/Model/Sources/Common/BaseResponseDTO.swift @@ -0,0 +1,32 @@ +// +// BaseResponseDTO.swift +// Model +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +/// 서버 공통 응답 봉투 +/// ```json +/// { +/// "statusCode": 0, +/// "data": { ... }, +/// "error": { "code": "...", "message": "..." } +/// } +/// ``` +public struct BaseResponseDTO: Decodable { + public let statusCode: Int + public let data: T? + public let error: APIErrorDTO? + + public init( + statusCode: Int, + data: T?, + error: APIErrorDTO? + ) { + self.statusCode = statusCode + self.data = data + self.error = error + } +} diff --git a/Projects/Data/Repository/Project.swift b/Projects/Data/Repository/Project.swift index 4d2996b..dae8b2a 100644 --- a/Projects/Data/Repository/Project.swift +++ b/Projects/Data/Repository/Project.swift @@ -1,20 +1,26 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin - let project = Project.makeModule( name: "Repository", bundleId: .appBundleID(name: ".Repository"), product: .staticFramework, - settings: .settings(), + settings: .settings(), dependencies: [ .Network(implements: .Networking), + .Network(implements: .Foundations), + .Data(implements: .Service), + .Data(implements: .Model), .Domain(implements: .DomainInterface), - .SPM.mixpanel - + .SPM.asyncMoya, + .SPM.composableArchitecture, + .SPM.weaveDI, + .SPM.logMarco, + .SPM.mixpanel, + .SPM.googleSignIn, ], sources: ["Sources/**"], hasTests: true diff --git a/Projects/Data/Repository/Sources/Auth/Interceptor/AuthInterceptor.swift b/Projects/Data/Repository/Sources/Auth/Interceptor/AuthInterceptor.swift new file mode 100644 index 0000000..53842a8 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/Interceptor/AuthInterceptor.swift @@ -0,0 +1,210 @@ +// +// AuthInterceptor.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Alamofire +import ComposableArchitecture +import Dependencies +import DomainInterface +import Entity +import Foundation +import LogMacro +import Moya +import UIKit + +// MARK: - Notification + +public extension NSNotification.Name { + /// 리프레시 토큰 만료 시 발송되는 알림 + static let refreshTokenExpired = NSNotification.Name("RefreshTokenExpired") +} + +// MARK: - Token Refresh Manager + +actor TokenRefreshManager { + @Dependency(\.authRepository) private var authRepository + @Dependency(\.keychainManager) private var keychainManager + + private var isRefreshing = false + + func refreshCredentialIfNeeded() async throws -> AccessTokenCredential { + // 다른 요청이 이미 refresh 중이면 잠시 대기 후 최신 credential 흐름을 다시 탄다. + if isRefreshing { + try await _Concurrency.Task.sleep(nanoseconds: 100_000_000) + return try await refreshCredentialIfNeeded() + } + + isRefreshing = true + defer { isRefreshing = false } + + return try await performTokenRefresh() + } + + private func performTokenRefresh() async throws -> AccessTokenCredential { + Log.debug("🔄 Starting token refresh...") + + do { + let tokens = try await authRepository.refresh() + Log.debug("✅ Token refresh completed: \(tokens)") + + keychainManager.save(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) + + let newCredential = AccessTokenCredential.make( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) + + await MainActor.run { + AuthSessionManager.shared.credential = newCredential + OptimizedSessionManager.shared.credential = newCredential + } + + return newCredential + } catch { + Log.error("❌ Token refresh failed: \(error)") + + if isRefreshTokenExpiredError(error) { + await performAutomaticLogout() + throw AuthError.refreshTokenExpired + } else { + throw error + } + } + } + + private func isRefreshTokenExpiredError(_ error: Error) -> Bool { + let errorString = String(describing: error) + if errorString.contains("statusCodeError(401)") { return true } + + if let moyaError = error as? MoyaError { + switch moyaError { + case let .statusCode(response): + if response.statusCode == 401 { return true } + case let .underlying(_, response): + if response?.statusCode == 401 { return true } + default: + break + } + } + + if let authError = error as? AuthError, authError.isTokenExpiredError { + return true + } + + let desc = error.localizedDescription.lowercased() + return desc.contains("401") + || desc.contains("unauthorized") + || desc.contains("유효하지 않은 토큰") + || desc.contains("token expired") + || desc.contains("invalid token") + || desc.contains("authentication failed") + } + + private func performAutomaticLogout() async { + Log.debug("🚪 Performing automatic logout (401 detected)") + + keychainManager.clear() + + await MainActor.run { + AuthSessionManager.shared.credential = nil + OptimizedSessionManager.shared.credential = nil + + NotificationCenter.default.post( + name: .refreshTokenExpired, + object: nil, + userInfo: ["reason": "401_refresh_failed"] + ) + } + } +} + +// MARK: - Auth Interceptor + +final class AuthInterceptor: RequestInterceptor, @unchecked Sendable { + private let tokenRefreshManager = TokenRefreshManager() + + /// AsyncMoya 요청에 토큰을 추가한다. + func addAuthToken(to urlRequest: URLRequest) async throws -> URLRequest { + var authenticatedRequest = urlRequest + + guard let credential = AuthSessionManager.shared.credential else { + Log.debug("⚠️ No credential available, proceeding without token") + return urlRequest + } + + if credential.requiresRefresh { + Log.debug("🔄 Token refresh required, refreshing...") + let newCredential = try await tokenRefreshManager.refreshCredentialIfNeeded() + authenticatedRequest.setValue("Bearer \(newCredential.accessToken)", forHTTPHeaderField: "Authorization") + } else { + authenticatedRequest.setValue("Bearer \(credential.accessToken)", forHTTPHeaderField: "Authorization") + } + + return authenticatedRequest + } + + /// 401 발생 시 토큰을 갱신한다. + func handleUnauthorizedError() async throws -> AccessTokenCredential { + Log.debug("🚨 401 Unauthorized detected, attempting token refresh") + return try await tokenRefreshManager.refreshCredentialIfNeeded() + } + + func adapt( + _ urlRequest: URLRequest, + for _: Session, + completion: @escaping (Result) -> Void + ) { + var adapted = urlRequest + + guard let credential = AuthSessionManager.shared.credential else { + completion(.success(urlRequest)) + return + } + + if credential.requiresRefresh { + _Concurrency.Task { + do { + let newCredential = try await tokenRefreshManager.refreshCredentialIfNeeded() + adapted.headers.update(.authorization(bearerToken: newCredential.accessToken)) + completion(.success(adapted)) + } catch { + Log.error("❌ Token refresh failed in adapt: \(error)") + completion(.failure(error)) + } + } + } else { + adapted.headers.update(.authorization(bearerToken: credential.accessToken)) + completion(.success(adapted)) + } + } + + func retry( + _ request: Request, + for _: Session, + dueTo error: Error, + completion: @escaping (RetryResult) -> Void + ) { + guard let response = request.response, response.statusCode == 401 else { + completion(.doNotRetryWithError(error)) + return + } + + Log.debug("🚨 401 detected, attempting token refresh for retry") + + _Concurrency.Task { + do { + _ = try await tokenRefreshManager.refreshCredentialIfNeeded() + completion(.retry) + } catch { + if let authError = error as? AuthError, authError.isTokenExpiredError { + completion(.doNotRetryWithError(authError)) + } else { + completion(.doNotRetryWithError(error)) + } + } + } + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Pool/MoyaProviderPool.swift b/Projects/Data/Repository/Sources/Auth/Pool/MoyaProviderPool.swift new file mode 100644 index 0000000..46abdbf --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/Pool/MoyaProviderPool.swift @@ -0,0 +1,57 @@ +// +// MoyaProviderPool.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import AsyncMoya +import Foundation +import Moya + +/// MoyaProvider 재사용 풀 (메모리 최적화) +public final class MoyaProviderPool: @unchecked Sendable { + public static let shared = MoyaProviderPool() + + private var defaultProviders: [String: Any] = [:] + private var authorizedProviders: [String: Any] = [:] + private let queue = DispatchQueue(label: "picke.moya.provider.pool", attributes: .concurrent) + + private init() {} + + /// 기본 Provider 반환 (재사용) + public func defaultProvider(for targetType: T.Type) -> MoyaProvider { + let key = String(describing: targetType) + + return queue.sync(flags: .barrier) { + if let existing = defaultProviders[key] as? MoyaProvider { + return existing + } + let new = MoyaProvider.default + defaultProviders[key] = new + return new + } + } + + /// 인증된 Provider 반환 (재사용) + public func authorizedProvider(for targetType: T.Type) -> MoyaProvider { + let key = String(describing: targetType) + + return queue.sync(flags: .barrier) { + if let existing = authorizedProviders[key] as? MoyaProvider { + return existing + } + let new = MoyaProvider.authorized + authorizedProviders[key] = new + return new + } + } + + /// 풀 정리 (메모리 경고 시 호출) + func clearPool() { + queue.async(flags: .barrier) { + self.defaultProviders.removeAll() + self.authorizedProviders.removeAll() + } + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift new file mode 100644 index 0000000..9f4e006 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift @@ -0,0 +1,69 @@ +// +// AccessTokenCredential.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation +import LogMacro + +struct AccessTokenCredential: Sendable { + let accessToken: String + let refreshToken: String + let expiration: Date + + private let refreshLeadTime: TimeInterval = 5 * 60 + + var requiresRefresh: Bool { + Date().addingTimeInterval(refreshLeadTime) >= expiration + } + + var isExpired: Bool { + Date() >= expiration + } + + static func make( + accessToken: String, + refreshToken: String + ) -> AccessTokenCredential { + let fallbackExpiration = Date().addingTimeInterval(24 * 60 * 60) + let expiration: Date + if let decodedExpiration = decodeExpiration(from: accessToken) { + expiration = decodedExpiration + } else { + Log.debug("⚠️ JWT decoding failed, using fallback expiration: 24h from now") + expiration = fallbackExpiration + } + + return AccessTokenCredential( + accessToken: accessToken, + refreshToken: refreshToken, + expiration: expiration + ) + } +} + +private extension AccessTokenCredential { + static func decodeExpiration(from token: String) -> Date? { + let components = token.components(separatedBy: ".") + guard components.count == 3 else { return nil } + + let payload = components[1] + var base64 = payload + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let paddingLength = 4 - (base64.count % 4) + if paddingLength < 4 { + base64 += String(repeating: "=", count: paddingLength) + } + + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = json["exp"] as? TimeInterval + else { return nil } + + return Date(timeIntervalSince1970: exp) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift new file mode 100644 index 0000000..1ad60c9 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift @@ -0,0 +1,86 @@ +// +// AuthSessionManager.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Alamofire +import DomainInterface +import Entity +import Foundation +import UIKit +import WeaveDI + +final class AuthSessionManager { + static let shared = AuthSessionManager() + + @Dependency(\.keychainManager) var keychainManager + + var credential: AccessTokenCredential? + let session: Session + + private var memoryCleanupTimer: Timer? + + private init() { + session = Session(interceptor: AuthInterceptor()) + setupInitialCredential() + setupMemoryOptimization() + } + + deinit { + memoryCleanupTimer?.invalidate() + } + + func updateCredential(with tokens: AuthTokens) { + credential = AccessTokenCredential.make( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) + } + + func clear() { + credential = nil + forceMemoryCleanup() + } + + private func setupMemoryOptimization() { + memoryCleanupTimer = Timer.scheduledTimer(withTimeInterval: 1800, repeats: true) { [weak self] _ in + self?.performPeriodicCleanup() + } + + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.forceMemoryCleanup() + } + } + + private func performPeriodicCleanup() { + if let credential, credential.isExpired { + self.credential = nil + } + } + + private func forceMemoryCleanup() { + credential = nil + session.session.configuration.urlCache?.removeAllCachedResponses() + } +} + +private extension AuthSessionManager { + func setupInitialCredential() { + if let loaded = loadCredentialFromKeychain() { + credential = loaded + } + } + + func loadCredentialFromKeychain() -> AccessTokenCredential? { + let access = keychainManager.accessToken() + let refresh = keychainManager.refreshToken() + guard let access, let refresh, !access.isEmpty, !refresh.isEmpty else { return nil } + return AccessTokenCredential.make(accessToken: access, refreshToken: refresh) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift new file mode 100644 index 0000000..85e2c7d --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift @@ -0,0 +1,25 @@ +// +// Extension+MoyaProvider+Auth.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +import AsyncMoya +import Foundations + +public extension MoyaProvider { + /// 인증된 세션(인터셉터 부착) 기반의 Provider + static var authorized: MoyaProvider { + let manager = OptimizedSessionManager.shared + + return MoyaProvider( + session: manager.session, + plugins: [ + MoyaLoggingPlugin(), + ] + ) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift new file mode 100644 index 0000000..e1b0489 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift @@ -0,0 +1,21 @@ +// +// Extension+MoyaProvider+Response.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +import AsyncMoya +import Moya + +public extension MoyaProvider { + func requestResponse(_ target: Target) async throws -> Response { + try await withCheckedThrowingContinuation { continuation in + request(target) { result in + continuation.resume(with: result) + } + } + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/OptimizedSessionManager.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/OptimizedSessionManager.swift new file mode 100644 index 0000000..d52b44b --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/OptimizedSessionManager.swift @@ -0,0 +1,82 @@ +// +// OptimizedSessionManager.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Alamofire +import DomainInterface +import Entity +import Foundation +import WeaveDI + +/// 네트워킹 성능 최적화된 세션 매니저 +final class OptimizedSessionManager { + static let shared = OptimizedSessionManager() + + @Dependency(\.keychainManager) var keychainManager + + var credential: AccessTokenCredential? + let session: Session + + private init() { + let configuration = URLSessionConfiguration.default + + configuration.httpMaximumConnectionsPerHost = 6 + configuration.requestCachePolicy = .useProtocolCachePolicy + + configuration.timeoutIntervalForRequest = 30.0 + configuration.timeoutIntervalForResource = 120.0 + + configuration.urlCache = URLCache( + memoryCapacity: 50 * 1024 * 1024, + diskCapacity: 200 * 1024 * 1024, + diskPath: "picke_network_cache" + ) + + configuration.multipathServiceType = .handover + configuration.allowsCellularAccess = true + configuration.allowsExpensiveNetworkAccess = true + configuration.allowsConstrainedNetworkAccess = false + + configuration.httpAdditionalHeaders = [ + "Connection": "keep-alive", + "Keep-Alive": "timeout=120, max=1000", + ] + + session = Session( + configuration: configuration, + interceptor: AuthInterceptor() + ) + + setupInitialCredential() + } + + func updateCredential(with tokens: AuthTokens) { + credential = AccessTokenCredential.make( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) + } + + func clear() { + credential = nil + session.session.configuration.urlCache?.removeAllCachedResponses() + } +} + +private extension OptimizedSessionManager { + func setupInitialCredential() { + if let loaded = loadCredentialFromKeychain() { + credential = loaded + } + } + + func loadCredentialFromKeychain() -> AccessTokenCredential? { + let access = keychainManager.accessToken() + let refresh = keychainManager.refreshToken() + guard let access, let refresh, !access.isEmpty, !refresh.isEmpty else { return nil } + return AccessTokenCredential.make(accessToken: access, refreshToken: refresh) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift new file mode 100644 index 0000000..74ed4f6 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,151 @@ +// +// AuthRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import Dependencies +import LogMacro +import Moya +import WeaveDI + +@preconcurrency import AsyncMoya + +public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { + @Dependency(\.keychainManager) private var keychainManager + + private let provider: MoyaProvider + private let authProvider: MoyaProvider + + public init( + provider: MoyaProvider? = nil, + authProvider: MoyaProvider? = nil + ) { + self.provider = provider ?? MoyaProviderPool.shared.defaultProvider(for: AuthService.self) + self.authProvider = authProvider ?? MoyaProviderPool.shared.authorizedProvider(for: AuthService.self) + } + + // MARK: - 로그인 + + public func login( + provider socialProvider: SocialType, + authorizationCode: String, + redirectUri: String + ) async throws -> LoginEntity { + let dto: LoginResponseDTO = try await provider.request( + .login( + provider: socialProvider, + body: OAuthLoginRequest( + authorizationCode: authorizationCode, + redirectUri: redirectUri + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "로그인 응답이 비어 있습니다" + throw AuthError.backendError(message) + } + + return data.toDomain(provider: socialProvider) + } + + // MARK: - 토큰 재발급 + + public func refresh() async throws -> AuthTokens { + let refreshToken = keychainManager.refreshToken() ?? "" + + do { + let dto: RefreshResponseDTO = try await provider.request(.refresh(refreshToken: refreshToken)) + guard let token = dto.data else { + let message = dto.error?.message ?? "토큰 재발급 응답이 비어 있습니다" + throw AuthError.backendError(message) + } + return token.toDomain() + } catch { + Log.error("🔍 [AuthRepositoryImpl] Refresh failed: \(error)") + + if let moyaError = error as? MoyaError { + switch moyaError { + case let .statusCode(response) where response.statusCode == 401, + let .underlying(_, response?) where response.statusCode == 401: + throw AuthError.refreshTokenExpired + default: + break + } + } + + let errorString = String(describing: error) + if errorString.contains("statusCodeError(401)") { + throw AuthError.refreshTokenExpired + } + + throw error + } + } + + // MARK: - 로그아웃 + + public func logout() async throws -> AuthExitEntity { + let response = try await authProvider.requestResponse(.logout) + let decoder = JSONDecoder() + + if (200 ... 299).contains(response.statusCode) { + clearLocalSession() + if response.data.isEmpty { return AuthExitEntity(loggedOut: true) } + if let success = try? decoder.decode(LogOutDTO.self, from: response.data) { + return success.toDomain() + } + return AuthExitEntity(loggedOut: true) + } + + if let errorDTO = try? decoder.decode(LogOutDTO.self, from: response.data) { + return errorDTO.toDomain() + } + return AuthExitEntity(message: String(data: response.data, encoding: .utf8)) + } + + // MARK: - 회원 탈퇴 + + public func withDraw(token: String) async throws -> WithdrawEntity { + let response = try await provider.requestResponse(.withdraw(token: token)) + let decoder = JSONDecoder() + + if (200 ... 299).contains(response.statusCode) { + if response.data.isEmpty { return WithdrawEntity(isSuccess: true, withdrawn: true) } + if let success = try? decoder.decode(WithdrawDTO.self, from: response.data) { + return success.toDomain(isSuccess: true) + } + return WithdrawEntity(isSuccess: true, withdrawn: true) + } + + if let errorDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) { + return errorDTO.toDomain(isSuccess: false) + } + return WithdrawEntity( + isSuccess: false, + message: String(data: response.data, encoding: .utf8) + ) + } + + // MARK: - 세션 Credential 업데이트 + + public func updateSessionCredential(with tokens: AuthTokens) { + AuthSessionManager.shared.updateCredential(with: tokens) + OptimizedSessionManager.shared.updateCredential(with: tokens) + } + + private func clearLocalSession() { + keychainManager.clear() + AuthSessionManager.shared.clear() + OptimizedSessionManager.shared.clear() + } +} diff --git a/Projects/Data/Repository/Sources/Base.swift b/Projects/Data/Repository/Sources/Base.swift deleted file mode 100644 index 6297cc4..0000000 --- a/Projects/Data/Repository/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift b/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift new file mode 100644 index 0000000..6feac25 --- /dev/null +++ b/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift @@ -0,0 +1,30 @@ +// +// GoogleOAuthConfiguration.swift +// Repository +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +public struct GoogleOAuthConfiguration { + public let clientID: String + public let serverClientID: String + + public static var current: GoogleOAuthConfiguration { + let clientID = "\(Bundle.main.object(forInfoDictionaryKey: "GOOGLE_IOS_CLIENT_ID") as? String ?? "")" + let serverClientID = "\(Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_ID") as? String ?? "")" + return GoogleOAuthConfiguration(clientID: clientID, serverClientID: serverClientID) + } + + public var isValid: Bool { + !clientID.contains("YOUR_GOOGLE_IOS_CLIENT_ID") && + !serverClientID.contains("GOOGLE_CLIENT_ID") + } + + public init(clientID: String, serverClientID: String) { + self.clientID = clientID + self.serverClientID = serverClientID + } +} + diff --git a/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift new file mode 100644 index 0000000..fc3221c --- /dev/null +++ b/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift @@ -0,0 +1,79 @@ +// +// GoogleOAuthRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 12/29/25. +// + +import AuthenticationServices +import DomainInterface +import Entity +import Foundation +import LogMacro +import UIKit + +/// Google OAuth — WKWebView 로 authorize URL 띄우고 redirect 콜백을 navigation 단계에서 가로채는 흐름. +/// 1) +/// `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri={BASE_URL}/oauth/google&...` +/// 2) 사용자 동의 → 구글이 `{BASE_URL}/oauth/google?code=...` 로 리다이렉트 시도 +/// 3) WKWebView 가 해당 URL 로 이동하기 전에 navigation 을 cancel 하고 `code` 만 추출 +/// (서버 401 응답은 송신되지 않고 사용자에게도 노출되지 않음) +@MainActor +public final class GoogleOAuthRepositoryImpl: NSObject, GoogleOAuthInterface { + private let redirectPath = "/oauth/google" + private let scope = "email profile" + private var serverRedirectUri: String { OAuthRedirectConfiguration.redirectURI(path: redirectPath) } + private var redirectHost: String { OAuthRedirectConfiguration.redirectHost } + + /// DI 호환을 위해 유지 (WKWebView 기반에서는 미사용) + private let presentationContextProvider: ASWebAuthenticationPresentationContextProviding + + public init(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { + self.presentationContextProvider = presentationContextProvider + } + + public func signIn() async throws -> GoogleOAuthPayload { + guard let clientID = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_ID") as? String, + !clientID.isEmpty + else { + throw AuthError.configurationMissing + } + + let authorizeURL = try buildAuthorizeURL(clientID: clientID) + Log.debug("google authorize", authorizeURL.absoluteString) + + let code = try await OAuthWebPresenter.present( + authorizeURL: authorizeURL, + redirectHost: redirectHost, + redirectPath: redirectPath, + customUserAgent: OAuthWebUserAgent.mobileSafari + ) + Log.debug("google authorizationCode", code) + + return GoogleOAuthPayload( + idToken: "", + accessToken: nil, + authorizationCode: code, + displayName: nil, + redirectUri: serverRedirectUri + ) + } +} + +private extension GoogleOAuthRepositoryImpl { + func buildAuthorizeURL(clientID: String) throws -> URL { + var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth") + components?.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: serverRedirectUri), + URLQueryItem(name: "scope", value: scope), + URLQueryItem(name: "prompt", value: "select_account"), + URLQueryItem(name: "access_type", value: "offline"), + ] + guard let url = components?.url else { + throw AuthError.invalidCredential("Google authorize URL 생성 실패") + } + return url + } +} diff --git a/Projects/Data/Repository/Sources/OAuth/Apple/AppleLoginRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Apple/AppleLoginRepositoryImpl.swift new file mode 100644 index 0000000..ea36737 --- /dev/null +++ b/Projects/Data/Repository/Sources/OAuth/Apple/AppleLoginRepositoryImpl.swift @@ -0,0 +1,71 @@ +// +// AppleLoginRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 12/26/25. +// + + +import Foundation + +import AuthenticationServices + +import DomainInterface +import CryptoKit + +public struct AppleLoginRepositoryImpl: AppleAuthRequestInterface { + + public init() {} + + public func prepare(_ request: ASAuthorizationAppleIDRequest) -> String { + let nonce = randomNonceString() + request.requestedScopes = [.email, .fullName] + request.nonce = sha256(nonce) + return nonce + } + + public func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString + } + + public func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remainingLength = length + + while remainingLength > 0 { + let randoms: [UInt8] = (0 ..< 16).map { _ in + var random: UInt8 = 0 + let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) + if errorCode != errSecSuccess { + fatalError( + "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)" + ) + } + return random + } + + randoms.forEach { random in + if remainingLength == 0 { + return + } + + if random < charset.count { + result.append(charset[Int(random)]) + remainingLength -= 1 + } + } + } + + return result + } +} + diff --git a/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift new file mode 100644 index 0000000..a5aeaf3 --- /dev/null +++ b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift @@ -0,0 +1,154 @@ +// +// AppleOAuthRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation +import AuthenticationServices + +import DomainInterface +@preconcurrency import Entity + +import LogMacro +import WeaveDI +import ComposableArchitecture + +#if canImport(UIKit) +import UIKit +#endif + +public final class AppleOAuthRepositoryImpl: NSObject, AppleOAuthInterface, @unchecked Sendable { + private let logger = LogMacro.Log.self + @Dependency(\.appleManger) var appleLoginManger + @Shared(.appStorage("appleUserName")) var appleUserName: String? + + private var currentNonce: String? + private var signInContinuation: CheckedContinuation? + private var isSigningIn: Bool = false + + public override init() { + + } + public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload { + // 받은 credential으로 직접 payload 생성 + guard let identityTokenData = credential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) + else { + throw AuthError.missingIDToken + } + + let authorizationCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) } + let displayName = formatDisplayName(credential.fullName) + + return AppleOAuthPayload( + idToken: identityToken, + authorizationCode: authorizationCode, + displayName: displayName, + nonce: nonce + ) + } + + @MainActor + public func signIn() async throws -> AppleOAuthPayload { + // 이미 진행 중인 로그인이 있으면 기다림 + if isSigningIn { + return try await withCheckedThrowingContinuation { newContinuation in + newContinuation.resume(throwing: AuthError.invalidCredential("이미 로그인이 진행 중입니다")) + } + } + + return try await withCheckedThrowingContinuation { continuation in + self.isSigningIn = true + self.signInContinuation = continuation + + let request = ASAuthorizationAppleIDProvider().createRequest() + let nonce = appleLoginManger.prepare(request) + self.currentNonce = nonce + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func formatDisplayName(_ components: PersonNameComponents?) -> String? { + guard let components else { return nil } + let formatter = PersonNameComponentsFormatter() + let name = formatter.string(from: components).trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? nil : name + } + + private func finishSignIn(with result: Result) { + let continuation = signInContinuation + signInContinuation = nil + currentNonce = nil + isSigningIn = false + continuation?.resume(with: result) + } +} + +// MARK: - ASAuthorizationControllerDelegate +extension AppleOAuthRepositoryImpl: ASAuthorizationControllerDelegate { + public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + finishSignIn(with: .failure(AuthError.invalidCredential("Invalid credential type"))) + return + } + + guard let nonce = currentNonce else { + finishSignIn(with: .failure(AuthError.missingIDToken)) + return + } + + guard let identityTokenData = credential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) else { + finishSignIn(with: .failure(AuthError.missingIDToken)) + return + } + + let displayName = formatDisplayName(credential.fullName) + let authorizationCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) } + + let payload = AppleOAuthPayload( + idToken: identityToken, + authorizationCode: authorizationCode, + displayName: displayName, + nonce: nonce + ) + + self.$appleUserName.withLock { $0 = displayName } + + logger.info("Apple Sign In successful for user: \(displayName ?? "unknown"), \(appleUserName)") + finishSignIn(with: .success(payload)) + } + + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let nsError = error as NSError + + if nsError.code == ASAuthorizationError.canceled.rawValue { + finishSignIn(with: .failure(AuthError.userCancelled)) + } else { + logger.error("Apple Sign In failed: \(error.localizedDescription)") + finishSignIn(with: .failure(AuthError.invalidCredential(error.localizedDescription))) + } + } +} + +// MARK: - ASAuthorizationControllerPresentationContextProviding +extension AppleOAuthRepositoryImpl: ASAuthorizationControllerPresentationContextProviding { + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { +#if canImport(UIKit) + return UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first ?? ASPresentationAnchor() +#else + return ASPresentationAnchor() +#endif + } +} diff --git a/Projects/Data/Repository/Sources/OAuth/Google/GoogleLoginManager.swift b/Projects/Data/Repository/Sources/OAuth/Google/GoogleLoginManager.swift new file mode 100644 index 0000000..98b7df3 --- /dev/null +++ b/Projects/Data/Repository/Sources/OAuth/Google/GoogleLoginManager.swift @@ -0,0 +1,23 @@ +// +// GoogleLoginManager.swift +// Repository +// +// Created by Wonji Suh on 7/23/25. +// + +import CryptoKit +import SwiftUI + +struct GoogleLoginManager { + static let shared = GoogleLoginManager() + + func getRootViewController()->UIViewController{ + guard let screen = UIApplication.shared.connectedScenes.first as? UIWindowScene else{ + return .init() + } + guard let root = screen.windows.first?.rootViewController else{ + return .init() + } + return root + } +} diff --git a/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift b/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift new file mode 100644 index 0000000..8031fee --- /dev/null +++ b/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift @@ -0,0 +1,74 @@ +// +// KakaoOAuthRepository.swift +// Data +// +// Created by Wonji Suh on 12/05/25. +// + +import AuthenticationServices +import DomainInterface +import Entity +import Foundation +import LogMacro +import UIKit + +/// Kakao OAuth — WKWebView 로 authorize URL 띄우고 redirect 콜백을 navigation 단계에서 가로채는 흐름. +/// 1) `https://kauth.kakao.com/oauth/authorize?response_type=code&redirect_uri={BASE_URL}/oauth/kakao&...` +/// 2) 사용자 동의 → 카카오가 `{BASE_URL}/oauth/kakao?code=...` 로 리다이렉트 시도 +/// 3) WKWebView 가 해당 URL 로 이동하기 전에 navigation 을 cancel 하고 `code` 만 추출 +/// (서버 401 응답은 송신되지 않고 사용자에게도 노출되지 않음) +@MainActor +public final class KakaoOAuthRepository: NSObject, KakaoOAuthInterface { + private let redirectPath = "/oauth/kakao" + private var serverRedirectUri: String { OAuthRedirectConfiguration.redirectURI(path: redirectPath) } + private var redirectHost: String { OAuthRedirectConfiguration.redirectHost } + + /// DI 호환을 위해 유지 (WKWebView 기반에서는 미사용) + private let presentationContextProvider: ASWebAuthenticationPresentationContextProviding + + public init(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { + self.presentationContextProvider = presentationContextProvider + } + + public func signIn() async throws -> KakaoOAuthPayload { + guard let clientID = Bundle.main.object(forInfoDictionaryKey: "KAKAO_REST_API_KEY") as? String, + !clientID.isEmpty + else { + throw AuthError.configurationMissing + } + + let authorizeURL = try buildAuthorizeURL(clientID: clientID) + Log.debug("kakao authorize", authorizeURL.absoluteString) + + let code = try await OAuthWebPresenter.present( + authorizeURL: authorizeURL, + redirectHost: redirectHost, + redirectPath: redirectPath + ) + Log.debug("kakao authorizationCode", code) + + return KakaoOAuthPayload( + idToken: "", + accessToken: "", + authorizationCode: code, + displayName: nil, + redirectUri: serverRedirectUri + ) + } +} + +private extension KakaoOAuthRepository { + func buildAuthorizeURL(clientID: String) throws -> URL { + var components = URLComponents(string: "https://kauth.kakao.com/oauth/authorize") + components?.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: serverRedirectUri), + URLQueryItem(name: "prompt", value: "login"), + ] + guard let url = components?.url else { + throw AuthError.invalidCredential("Kakao authorize URL 생성 실패") + } + return url + } +} diff --git a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift new file mode 100644 index 0000000..8e4c6e0 --- /dev/null +++ b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift @@ -0,0 +1,420 @@ +// +// OAuthWebViewController.swift +// Repository +// +// Created by Wonji Suh on 5/15/26. +// + +import Combine +import Entity +import Foundation +import UIKit +import WebKit + +/// Google / Kakao OAuth authorize URL 을 WKWebView 에 띄우고, +/// 서버 콜백 URL (`{BASE_URL}/oauth/`) 로 네비게이션이 일어나면 +/// 요청을 보내기 전에 `?code=...` 만 추출해 webview 를 닫는다. +/// (서버가 401 응답을 내려도 그 요청이 송신되기 전에 cancel 되므로 사용자에게 노출되지 않음) +@MainActor +final class OAuthWebViewController: UIViewController { + private enum Layout { + static let sheetHeightRatio: CGFloat = 0.8 + static let dimmingAlpha: CGFloat = 0.35 + static let cornerRadius: CGFloat = 20 + static let grabberTopSpacing: CGFloat = 8 + static let grabberWidth: CGFloat = 36 + static let grabberHeight: CGFloat = 4 + static let dragHandleHeight: CGFloat = 28 + static let webViewTopSpacing: CGFloat = 8 + static let dismissDragThreshold: CGFloat = 96 + static let dragDismissDuration: TimeInterval = 0.18 + } + + private let authorizeURL: URL + private let redirectHost: String + private let redirectPath: String + private let customUserAgent: String? + private let onComplete: (Result) -> Void + private let backgroundTapSubject = PassthroughSubject() + private let sheetDragSubject = PassthroughSubject() + private var cancellables: Set = [] + private var didFinish = false + private weak var dimmingControl: UIControl? + private weak var sheetContainer: UIView? + + private lazy var webView: WKWebView = { + let config = WKWebViewConfiguration() + let view = WKWebView(frame: .zero, configuration: config) + view.navigationDelegate = self + view.customUserAgent = customUserAgent + view.scrollView.showsVerticalScrollIndicator = false + view.scrollView.showsHorizontalScrollIndicator = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + init( + authorizeURL: URL, + redirectHost: String, + redirectPath: String, + customUserAgent: String? = nil, + onComplete: @escaping (Result) -> Void + ) { + self.authorizeURL = authorizeURL + self.redirectHost = redirectHost + self.redirectPath = redirectPath + self.customUserAgent = customUserAgent + self.onComplete = onComplete + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .coverVertical + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let rootView = UIView() + rootView.backgroundColor = .clear + + let dimmingControl = makeDimmingControl() + let sheetContainer = makeSheetContainer() + let dragHandleView = makeDragHandleView() + let grabberView = makeGrabberView() + self.dimmingControl = dimmingControl + self.sheetContainer = sheetContainer + + rootView.addSubview(dimmingControl) + rootView.addSubview(sheetContainer) + sheetContainer.addSubview(dragHandleView) + dragHandleView.addSubview(grabberView) + sheetContainer.addSubview(webView) + + NSLayoutConstraint.activate([ + dimmingControl.topAnchor.constraint(equalTo: rootView.topAnchor), + dimmingControl.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + dimmingControl.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + dimmingControl.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + + sheetContainer.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + sheetContainer.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + sheetContainer.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + sheetContainer.heightAnchor.constraint(equalTo: rootView.heightAnchor, multiplier: Layout.sheetHeightRatio), + + dragHandleView.topAnchor.constraint(equalTo: sheetContainer.topAnchor), + dragHandleView.leadingAnchor.constraint(equalTo: sheetContainer.leadingAnchor), + dragHandleView.trailingAnchor.constraint(equalTo: sheetContainer.trailingAnchor), + dragHandleView.heightAnchor.constraint(equalToConstant: Layout.dragHandleHeight), + + grabberView.topAnchor.constraint(equalTo: dragHandleView.topAnchor, constant: Layout.grabberTopSpacing), + grabberView.centerXAnchor.constraint(equalTo: dragHandleView.centerXAnchor), + grabberView.widthAnchor.constraint(equalToConstant: Layout.grabberWidth), + grabberView.heightAnchor.constraint(equalToConstant: Layout.grabberHeight), + + webView.topAnchor.constraint(equalTo: dragHandleView.bottomAnchor, constant: Layout.webViewTopSpacing), + webView.leadingAnchor.constraint(equalTo: sheetContainer.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: sheetContainer.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: sheetContainer.bottomAnchor), + ]) + + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + webView.load(URLRequest(url: authorizeURL)) + } + + private func makeDimmingControl() -> UIControl { + let control = UIControl() + control.backgroundColor = UIColor.black.withAlphaComponent(Layout.dimmingAlpha) + control.addAction( + UIAction { [weak self] _ in + Task { @MainActor [weak self] in + self?.backgroundTapSubject.send(()) + } + }, + for: .touchUpInside + ) + control.translatesAutoresizingMaskIntoConstraints = false + return control + } + + private func bind() { + backgroundTapSubject + .sink { [weak self] in + Task { @MainActor [weak self] in + self?.finish(.failure(AuthError.userCancelled)) + } + } + .store(in: &cancellables) + + sheetDragSubject + .sink { [weak self] event in + Task { @MainActor [weak self] in + self?.handleSheetDrag(event) + } + } + .store(in: &cancellables) + } + + private func makeSheetContainer() -> UIView { + let view = UIView() + view.backgroundColor = .systemBackground + view.layer.cornerRadius = Layout.cornerRadius + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + private func makeDragHandleView() -> OAuthSheetDragHandleView { + let view = OAuthSheetDragHandleView(events: sheetDragSubject) + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + private func makeGrabberView() -> UIView { + let view = UIView() + view.backgroundColor = .tertiaryLabel + view.layer.cornerRadius = Layout.grabberHeight / 2 + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + private func handleSheetDrag(_ event: OAuthSheetDragEvent) { + guard let sheetContainer else { return } + + switch event { + case let .changed(translationY): + sheetContainer.transform = CGAffineTransform(translationX: 0, y: max(0, translationY)) + + case let .ended(translationY): + if translationY >= Layout.dismissDragThreshold { + dismissFromDrag(sheetContainer: sheetContainer) + } else { + UIView.animate( + withDuration: 0.25, + delay: 0, + options: [.curveEaseOut, .allowUserInteraction] + ) { + sheetContainer.transform = .identity + } + } + + case .cancelled: + UIView.animate( + withDuration: 0.2, + delay: 0, + options: [.curveEaseOut, .allowUserInteraction] + ) { + sheetContainer.transform = .identity + } + } + } + + private func dismissFromDrag(sheetContainer: UIView) { + guard !didFinish else { return } + let targetY = max(view.bounds.height - sheetContainer.frame.minY, sheetContainer.bounds.height) + + UIView.animate( + withDuration: Layout.dragDismissDuration, + delay: 0, + options: [.curveEaseIn, .beginFromCurrentState, .allowUserInteraction] + ) { [weak self] in + sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetY) + self?.dimmingControl?.alpha = 0 + } completion: { [weak self] _ in + Task { @MainActor [weak self] in + self?.finish(.failure(AuthError.userCancelled), animated: false) + } + } + } + + private func finish(_ result: Result, animated: Bool = true) { + guard !didFinish else { return } + didFinish = true + let completion = onComplete + dismiss(animated: animated) { + completion(result) + } + } + + private func callbackResult(from components: URLComponents) -> Result { + if let error = components.queryItems?.first(where: { $0.name == "error" })?.value { + return .failure(AuthError.backendError(error)) + } + + guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value, + !code.isEmpty + else { + return .failure(AuthError.unknownError("OAuth authorization code 를 받지 못했습니다")) + } + + return .success(code) + } +} + +private enum OAuthSheetDragEvent { + case changed(CGFloat) + case ended(CGFloat) + case cancelled +} + +private final class OAuthSheetDragHandleView: UIControl { + private let events: PassthroughSubject + private var initialTouchY: CGFloat? + + init(events: PassthroughSubject) { + self.events = events + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func beginTracking(_ touch: UITouch, with _: UIEvent?) -> Bool { + initialTouchY = touch.location(in: nil).y + return true + } + + override func continueTracking(_ touch: UITouch, with _: UIEvent?) -> Bool { + guard let initialTouchY else { return false } + let currentY = touch.location(in: nil).y + events.send(.changed(currentY - initialTouchY)) + return true + } + + override func endTracking(_ touch: UITouch?, with _: UIEvent?) { + defer { initialTouchY = nil } + guard let touch, + let initialTouchY + else { + events.send(.cancelled) + return + } + + let currentY = touch.location(in: nil).y + events.send(.ended(currentY - initialTouchY)) + } + + override func cancelTracking(with _: UIEvent?) { + initialTouchY = nil + events.send(.cancelled) + } +} + +extension OAuthWebViewController: WKNavigationDelegate { + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + decisionHandler(.allow) + return + } + + let isCallback = url.host == redirectHost && url.path == redirectPath + guard isCallback else { + decisionHandler(.allow) + return + } + + decisionHandler(.cancel) + finish(callbackResult(from: components)) + } +} + +// MARK: - Presentation helper + +@MainActor +enum OAuthWebPresenter { + /// 현재 화면 위에 OAuth WebView 를 띄우고, code 를 비동기로 반환. + static func present( + authorizeURL: URL, + redirectHost: String, + redirectPath: String, + customUserAgent: String? = nil + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let controller = OAuthWebViewController( + authorizeURL: authorizeURL, + redirectHost: redirectHost, + redirectPath: redirectPath, + customUserAgent: customUserAgent, + onComplete: { result in + continuation.resume(with: result) + } + ) + + guard let top = topViewController() else { + continuation.resume(throwing: AuthError.missingPresentingController) + return + } + top.present(controller, animated: true) + } + } + + private static func topViewController( + base: UIViewController? = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .compactMap(\.keyWindow) + .first?.rootViewController + ) -> UIViewController? { + if let nav = base as? UINavigationController { + return topViewController(base: nav.visibleViewController) + } + if let tab = base as? UITabBarController, let selected = tab.selectedViewController { + return topViewController(base: selected) + } + if let presented = base?.presentedViewController { + return topViewController(base: presented) + } + return base + } +} + +enum OAuthWebUserAgent { + static var mobileSafari: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + let osVersion = "\(version.majorVersion)_\(version.minorVersion)" + + return """ + Mozilla/5.0 (iPhone; CPU iPhone OS \(osVersion) like Mac OS X) AppleWebKit/605.1.15 \ + (KHTML, like Gecko) Version/\(version.majorVersion).\(version.minorVersion) Mobile/15E148 Safari/604.1 + """ + } +} + +enum OAuthRedirectConfiguration { + static func redirectURI(path: String) -> String { + "\(baseURLString)\(path)" + } + + static var redirectHost: String { + URL(string: baseURLString)?.host ?? baseURLString + } + + private static var baseURLString: String { + let rawValue = Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String ?? "" + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + let configuredURL = if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { + trimmed + } else { + "https://\(trimmed)" + } + return configuredURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } +} diff --git a/Projects/Data/Service/Project.swift b/Projects/Data/Service/Project.swift index 50def2a..432729e 100644 --- a/Projects/Data/Service/Project.swift +++ b/Projects/Data/Service/Project.swift @@ -1,17 +1,19 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeModule( name: "Service", bundleId: .appBundleID(name: ".Service"), product: .staticFramework, - settings: .settings(), + settings: .settings(), dependencies: [ .Data(implements: .API), - .SPM.asyncMoya + .Network(implements: .Foundations), + .SPM.asyncMoya, + .Data(implements: .Model) ], sources: ["Sources/**"], hasTests: false diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift new file mode 100644 index 0000000..a5d05bc --- /dev/null +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -0,0 +1,82 @@ +// +// AuthService.swift +// Service +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +import API +import Entity +import Foundations + +import AsyncMoya + +public enum AuthService { + case login(provider: SocialType, body: OAuthLoginRequest) + case refresh(refreshToken: String) + case withdraw(token: String) + case logout +} + +extension AuthService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { + switch self { + case .login, .refresh, .logout: + return .auth + case .withdraw: + return .profile + } + } + + public var urlPath: String { + switch self { + case let .login(provider, _): + return "\(AuthAPI.login.description)/\(provider.rawValue)" + case .refresh: + return AuthAPI.refresh.description + case .withdraw: + return AuthAPI.withDraw.description + case .logout: + return AuthAPI.logout.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { + nil + } + + public var method: Moya.Method { + switch self { + case .login, .refresh, .logout: + .post + case .withdraw: + .delete + } + } + + public var parameters: [String: Any]? { + switch self { + case let .login(_, body): + body.toDictionary + case let .refresh(refreshToken): + refreshToken.toDictionary(key: "refreshToken") + case let .withdraw(token): + token.toDictionary(key: "token") + case .logout: + nil + } + } + + public var headers: [String: String]? { + switch self { + case .withdraw, .logout: + APIHeader.baseHeader + default: + APIHeader.notAccessTokenHeader + } + } +} diff --git a/Projects/Data/Service/Sources/Auth/OAuthRequest.swift b/Projects/Data/Service/Sources/Auth/OAuthRequest.swift new file mode 100644 index 0000000..21d7fd1 --- /dev/null +++ b/Projects/Data/Service/Sources/Auth/OAuthRequest.swift @@ -0,0 +1,22 @@ +// +// OAuthRequest.swift +// Service +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +/// `/api/v1/auth/login/{provider}` 요청 바디 +public struct OAuthLoginRequest: Encodable { + public let authorizationCode: String + public let redirectUri: String + + public init( + authorizationCode: String, + redirectUri: String + ) { + self.authorizationCode = authorizationCode + self.redirectUri = redirectUri + } +} diff --git a/Projects/Data/Service/Sources/Base.swift b/Projects/Data/Service/Sources/Base.swift deleted file mode 100644 index fc5212e..0000000 --- a/Projects/Data/Service/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Data/Service/Sources/Common/Encodable+.swift b/Projects/Data/Service/Sources/Common/Encodable+.swift new file mode 100644 index 0000000..29a671c --- /dev/null +++ b/Projects/Data/Service/Sources/Common/Encodable+.swift @@ -0,0 +1,32 @@ +// +// Encodable+.swift +// Service +// +// Created by Wonji Suh on 3/12/26. +// + +import Foundation + +extension Encodable { + /// `Encodable` 을 API 파라미터용 `[String: Any]` 로 변환. + /// JSON 표준은 `/` 의 이스케이프를 허용하지만 로그 가독성을 위해 비활성. + var toDictionary: [String: Any]? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + guard let data = try? encoder.encode(self) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} + +extension String { + /// 문자열을 지정된 키로 API 파라미터용 Dictionary 로 변환 + func toDictionary(key: String) -> [String: Any] { + [key: self] + } +} + +extension Int { + func toDictionary(key: String) -> [String: Any] { + [key: self] + } +} diff --git a/Projects/Domain/DomainInterface/Project.swift b/Projects/Domain/DomainInterface/Project.swift index e2dbe3a..c145905 100644 --- a/Projects/Domain/DomainInterface/Project.swift +++ b/Projects/Domain/DomainInterface/Project.swift @@ -11,7 +11,8 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Domain(implements: .Entity), - .SPM.weaveDI + .SPM.weaveDI, + .SPM.composableArchitecture ], sources: ["Sources/**"], hasTests: false diff --git a/Projects/Domain/DomainInterface/Sources/Apple/AppleAuthRequestInterface.swift b/Projects/Domain/DomainInterface/Sources/Apple/AppleAuthRequestInterface.swift new file mode 100644 index 0000000..9c98d6f --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Apple/AppleAuthRequestInterface.swift @@ -0,0 +1,36 @@ +// +// AppleAuthRequestInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 12/26/25. +// + +import Foundation +import AuthenticationServices +import WeaveDI + +public protocol AppleAuthRequestInterface: Sendable { + func prepare(_ request: ASAuthorizationAppleIDRequest) -> String +} + +///// OAuth Repository의 DependencyKey 구조체 +public struct AppleAuthRequestDependency: DependencyKey { + public static var liveValue: AppleAuthRequestInterface { + UnifiedDI.resolve(AppleAuthRequestInterface.self) ?? DefaultAppleAuthRequestImpl() + } + + public static var testValue: AppleAuthRequestInterface { + UnifiedDI.resolve(AppleAuthRequestInterface.self) ?? DefaultAppleAuthRequestImpl() + } + + public static var previewValue: AppleAuthRequestInterface = liveValue +} + +/// DependencyValues extension으로 간편한 접근 제공 +public extension DependencyValues { + var appleManger: AppleAuthRequestInterface { + get { self[AppleAuthRequestDependency.self] } + set { self[AppleAuthRequestDependency.self] = newValue } + } +} + diff --git a/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift new file mode 100644 index 0000000..8e2a7b3 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift @@ -0,0 +1,36 @@ +// +// AppleOAuthInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation +import AuthenticationServices + +import Entity + +import WeaveDI + +public protocol AppleOAuthInterface: Sendable { + func signIn() async throws -> AppleOAuthPayload + func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload +} + +// MARK: - Dependencies +public struct AppleOAuthRepositoryDependencyKey: DependencyKey { + public static var liveValue: AppleOAuthInterface { + UnifiedDI.resolve(AppleOAuthInterface.self) ?? MockAppleOAuthRepository() + } + public static var previewValue: AppleOAuthInterface { + UnifiedDI.resolve(AppleOAuthInterface.self) ?? MockAppleOAuthRepository() + } + public static var testValue: AppleOAuthInterface = MockAppleOAuthRepository() +} + +public extension DependencyValues { + var appleOAuthRepository: AppleOAuthInterface { + get { self[AppleOAuthRepositoryDependencyKey.self] } + set { self[AppleOAuthRepositoryDependencyKey.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthProviderInterface.swift new file mode 100644 index 0000000..814348c --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthProviderInterface.swift @@ -0,0 +1,68 @@ +// +// AppleOAuthProviderInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation +import AuthenticationServices +import WeaveDI +import Entity + +/// Apple OAuth Provider Interface 프로토콜 +public protocol AppleOAuthProviderInterface: Sendable { + func signInWithCredential( + credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload + + func signIn() async throws -> AppleOAuthPayload +} + +/// Apple OAuth Provider의 DependencyKey 구조체 +public struct AppleOAuthProviderDependency: DependencyKey { + public static var liveValue: AppleOAuthProviderInterface { + UnifiedDI.resolve(AppleOAuthProviderInterface.self) ?? MockAppleOAuthProvider() + } + + public static var testValue: AppleOAuthProviderInterface { + UnifiedDI.resolve(AppleOAuthProviderInterface.self) ?? MockAppleOAuthProvider() + } + + public static var previewValue: AppleOAuthProviderInterface = testValue +} + +/// DependencyValues extension으로 간편한 접근 제공 +public extension DependencyValues { + var appleOAuthProvider: AppleOAuthProviderInterface { + get { self[AppleOAuthProviderDependency.self] } + set { self[AppleOAuthProviderDependency.self] = newValue } + } +} + +/// 테스트용 Mock 구현체 +public struct MockAppleOAuthProvider: AppleOAuthProviderInterface { + public init() {} + + public func signInWithCredential( + credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload { + return AppleOAuthPayload( + idToken: "mock_id_token", + authorizationCode: "mock_auth_code", + displayName: "Mock User", + nonce: nonce + ) + } + + public func signIn() async throws -> AppleOAuthPayload { + return AppleOAuthPayload( + idToken: "mock_id_token", + authorizationCode: "mock_auth_code", + displayName: "Mock User", + nonce: "mock_nonce" + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Apple/DefaultAppleAuthRequestImpl.swift b/Projects/Domain/DomainInterface/Sources/Apple/DefaultAppleAuthRequestImpl.swift new file mode 100644 index 0000000..911dc97 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Apple/DefaultAppleAuthRequestImpl.swift @@ -0,0 +1,68 @@ +// +// DefaultAppleAuthRequestImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 12/26/25. +// + +import Foundation + +import AuthenticationServices +import CryptoKit + + +/// Default fallback implementation of AppleAuthRequestInterface +public struct DefaultAppleAuthRequestImpl: AppleAuthRequestInterface { + + public init() {} + + public func prepare(_ request: ASAuthorizationAppleIDRequest) -> String { + let nonce = randomNonceString() + request.requestedScopes = [.email, .fullName] + request.nonce = sha256(nonce) + return nonce + } + + private func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + return hashString + } + + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remainingLength = length + + while remainingLength > 0 { + let randoms: [UInt8] = (0 ..< 16).map { _ in + var random: UInt8 = 0 + let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) + if errorCode != errSecSuccess { + fatalError( + "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)" + ) + } + return random + } + + randoms.forEach { random in + if remainingLength == 0 { + return + } + + if random < charset.count { + result.append(charset[Int(random)]) + remainingLength -= 1 + } + } + } + + return result + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift new file mode 100644 index 0000000..3e92d83 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift @@ -0,0 +1,228 @@ +// +// MockAppleOAuthRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation +import AuthenticationServices +import Entity + +public actor MockAppleOAuthRepository: AppleOAuthInterface { + public init() {} + // MARK: - Configuration + public enum Configuration { + case success + case failure + case userCancelled + case invalidCredentials + case customUser(String) + case networkError + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .customUser, .customDelay: + return true + case .failure, .userCancelled, .invalidCredentials, .networkError: + return false + } + } + + var delay: TimeInterval { + switch self { + case .customDelay(let delay): + return delay + default: + return 0.1 + } + } + + var mockUserName: String { + switch self { + case .customUser(let name): + return name + default: + return "Mock Apple User" + } + } + + var error: MockAppleOAuthError? { + switch self { + case .success, .customUser, .customDelay: + return nil + case .failure: + return .signInFailed + case .userCancelled: + return .userCancelled + case .invalidCredentials: + return .invalidCredentials + case .networkError: + return .networkError + } + } + } + + // MARK: - State + + private var configuration: Configuration = .success + private var signInCallCount = 0 + private var lastSignInCall: Date? + + // MARK: - Public Configuration Methods + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + signInCallCount = 0 + lastSignInCall = nil + } + + public func getSignInCallCount() -> Int { + return signInCallCount + } + + public func getLastSignInCall() -> Date? { + return lastSignInCall + } + + public func reset() { + configuration = .success + signInCallCount = 0 + lastSignInCall = nil + } + + // MARK: - AppleOAuthRepositoryProtocol Implementation + + public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload { + // Track call + signInCallCount += 1 + lastSignInCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + // Return success payload using provided nonce + return AppleOAuthPayload( + idToken: createMockIDToken(), + authorizationCode: createMockAuthCode(), + displayName: configuration.mockUserName.isEmpty ? nil : configuration.mockUserName, + nonce: nonce + ) + } + + public func signIn() async throws -> AppleOAuthPayload { + // Track call + signInCallCount += 1 + lastSignInCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + // Return success payload + return AppleOAuthPayload( + idToken: createMockIDToken(), + authorizationCode: createMockAuthCode(), + displayName: configuration.mockUserName.isEmpty ? nil : configuration.mockUserName, + nonce: createMockNonce() + ) + } + + // MARK: - Private Helper Methods + + private func createMockIDToken() -> String { + let header = "eyJhbGciOiJSUzI1NiIsImtpZCI6Im1vY2sta2lkIn0" + let payload = "eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vY2suYXBwIiwic3ViIjoibW9jay5hcHBsZS51c2VyIn0" + let signature = "mock-apple-signature-\(UUID().uuidString.prefix(10))" + return "\(header).\(payload).\(signature)" + } + + private func createMockNonce() -> String { + return "mock-apple-nonce-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))" + } + + private func createMockAuthCode() -> String { + return "c_\(UUID().uuidString.prefix(20)).0.\(configuration.mockUserName.prefix(5))" + } +} + +// MARK: - Convenience Static Methods + +public extension MockAppleOAuthRepository { + + /// Creates a pre-configured actor for success scenario + static func success() -> MockAppleOAuthRepository { + return MockAppleOAuthRepository(configuration: .success) + } + + /// Creates a pre-configured actor for failure scenario + static func failure() -> MockAppleOAuthRepository { + return MockAppleOAuthRepository(configuration: .failure) + } + + /// Creates a pre-configured actor for user cancelled scenario + static func userCancelled() -> MockAppleOAuthRepository { + return MockAppleOAuthRepository(configuration: .userCancelled) + } + + /// Creates a pre-configured actor for custom user scenario + static func customUser(_ name: String) -> MockAppleOAuthRepository { + return MockAppleOAuthRepository(configuration: .customUser(name)) + } + + /// Creates a pre-configured actor for network error scenario + static func networkError() -> MockAppleOAuthRepository { + return MockAppleOAuthRepository(configuration: .networkError) + } + + /// Creates a pre-configured actor with custom delay + static func withDelay(_ delay: TimeInterval) -> MockAppleOAuthRepository { + return MockAppleOAuthRepository(configuration: .customDelay(delay)) + } +} + +// MARK: - Mock Errors + +public enum MockAppleOAuthError: Error, LocalizedError { + case signInFailed + case userCancelled + case invalidCredentials + case networkError + case missingIdentityToken + case unknownError + + public var errorDescription: String? { + switch self { + case .signInFailed: + return "Mock Apple OAuth sign in failed" + case .userCancelled: + return "Mock Apple OAuth user cancelled" + case .invalidCredentials: + return "Mock Apple OAuth invalid credentials" + case .networkError: + return "Mock Apple OAuth network error" + case .missingIdentityToken: + return "Mock Apple OAuth missing identity token" + case .unknownError: + return "Mock Apple OAuth unknown error" + } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift new file mode 100644 index 0000000..11d7bca --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -0,0 +1,43 @@ +// +// AuthInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation +import WeaveDI + +/// Auth 관련 비즈니스 로직을 위한 Interface 프로토콜 +public protocol AuthInterface: Sendable { + func login( + provider: SocialType, + authorizationCode: String, + redirectUri: String + ) async throws -> LoginEntity + func refresh() async throws -> AuthTokens + func withDraw(token: String) async throws -> WithdrawEntity + func logout() async throws -> AuthExitEntity + func updateSessionCredential(with tokens: AuthTokens) +} + +/// Auth Repository 의 DependencyKey 구조체 +public struct AuthRepositoryDependency: DependencyKey { + public static var liveValue: AuthInterface { + UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() + } + + public static var testValue: AuthInterface { + UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() + } + + public static var previewValue: AuthInterface = liveValue +} + +public extension DependencyValues { + var authRepository: AuthInterface { + get { self[AuthRepositoryDependency.self] } + set { self[AuthRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift new file mode 100644 index 0000000..a80261c --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift @@ -0,0 +1,56 @@ +// +// DefaultAuthRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation + +/// Auth Repository 의 기본 구현체 (테스트 / 프리뷰용 no-op) +public final class DefaultAuthRepositoryImpl: AuthInterface, @unchecked Sendable { + public init() {} + + public func login( + provider: SocialType, + authorizationCode _: String, + redirectUri _: String + ) async throws -> LoginEntity { + LoginEntity( + name: "Mock User", + isNewUser: false, + provider: provider, + token: AuthTokens( + accessToken: "mock_access_token_\(UUID().uuidString)", + refreshToken: "mock_refresh_token_\(UUID().uuidString)" + ), + userTag: "mock_tag", + status: "active" + ) + } + + public func refresh() async throws -> AuthTokens { + AuthTokens( + accessToken: "mock_refreshed_access_token_\(UUID().uuidString)", + refreshToken: "mock_refreshed_refresh_token_\(UUID().uuidString)" + ) + } + + public func withDraw(token _: String) async throws -> WithdrawEntity { + WithdrawEntity(isSuccess: true) + } + + public func logout() async throws -> AuthExitEntity { + AuthExitEntity( + loggedOut: true, + code: "200", + message: "로그아웃이 성공적으로 완료되었습니다.", + detail: "사용자 세션이 종료되었습니다." + ) + } + + public func updateSessionCredential(with _: AuthTokens) { + // no-op + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift new file mode 100644 index 0000000..6e8d2d6 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift @@ -0,0 +1,162 @@ +// +// MockAuthRepository.swift +// DomainInterface +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation + +public final class MockAuthRepository: AuthInterface, @unchecked Sendable { + // MARK: - Configuration + + public enum Configuration { + case success + case newUser + case invalidToken + case networkError + case refreshSuccess + case tokenExpired + case logoutSuccess + case serverError + case withdrawSuccess + case unauthorized + } + + // MARK: - State + + private var configuration: Configuration = .success + public private(set) var loginCallCount = 0 + public private(set) var refreshCallCount = 0 + public private(set) var logoutCallCount = 0 + public private(set) var withdrawCallCount = 0 + public private(set) var updateCredentialCallCount = 0 + public private(set) var lastUpdatedTokens: AuthTokens? + + // MARK: - Init + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + // MARK: - AuthInterface + + public func login( + provider: SocialType, + authorizationCode _: String, + redirectUri _: String + ) async throws -> LoginEntity { + loginCallCount += 1 + try await Task.sleep(for: .milliseconds(10)) + + switch configuration { + case .success: + return LoginEntity( + name: "Mock User", + isNewUser: false, + provider: provider, + token: AuthTokens( + accessToken: "mock-access-token", + refreshToken: "mock-refresh-token" + ), + userTag: "mock_tag", + status: "active" + ) + + case .newUser: + return LoginEntity( + name: "New User", + isNewUser: true, + provider: provider, + token: AuthTokens( + accessToken: "mock-access-token", + refreshToken: "mock-refresh-token" + ), + userTag: "new_tag", + status: "pending" + ) + + case .invalidToken: + throw MockAuthError.invalidToken + + case .networkError: + throw MockAuthError.networkError + + default: + throw MockAuthError.unknownError + } + } + + public func refresh() async throws -> AuthTokens { + refreshCallCount += 1 + try await Task.sleep(for: .milliseconds(10)) + + switch configuration { + case .success, .refreshSuccess: + return AuthTokens( + accessToken: "new-access-token", + refreshToken: "new-refresh-token" + ) + case .tokenExpired: + throw MockAuthError.tokenExpired + default: + throw MockAuthError.unknownError + } + } + + public func logout() async throws -> AuthExitEntity { + logoutCallCount += 1 + try await Task.sleep(for: .milliseconds(10)) + + switch configuration { + case .success, .logoutSuccess: + return AuthExitEntity(loggedOut: true) + case .serverError: + throw MockAuthError.serverError + default: + throw MockAuthError.unknownError + } + } + + public func withDraw(token _: String) async throws -> WithdrawEntity { + withdrawCallCount += 1 + try await Task.sleep(for: .milliseconds(10)) + + switch configuration { + case .success, .withdrawSuccess: + return WithdrawEntity(isSuccess: true) + case .unauthorized: + throw MockAuthError.unauthorized + default: + throw MockAuthError.unknownError + } + } + + public func updateSessionCredential(with tokens: AuthTokens) { + updateCredentialCallCount += 1 + lastUpdatedTokens = tokens + } +} + +// MARK: - Mock Errors + +public enum MockAuthError: Error, LocalizedError { + case invalidToken + case networkError + case tokenExpired + case serverError + case unauthorized + case unknownError + + public var errorDescription: String? { + switch self { + case .invalidToken: "Invalid authentication token" + case .networkError: "Network connection error" + case .tokenExpired: "Authentication token has expired" + case .serverError: "Internal server error" + case .unauthorized: "Unauthorized access" + case .unknownError: "Unknown authentication error" + } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthInterface.swift new file mode 100644 index 0000000..7ed1fbc --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthInterface.swift @@ -0,0 +1,32 @@ +// +// GoogleOAuthInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation +import Entity +import WeaveDI + +public protocol GoogleOAuthInterface: Sendable { + func signIn() async throws -> GoogleOAuthPayload +} + +public struct GoogleOAuthRepositoryDependencyKey: DependencyKey { + public static var liveValue: GoogleOAuthInterface { + UnifiedDI.resolve(GoogleOAuthInterface.self) ?? MockGoogleOAuthRepository() + } + public static var previewValue: GoogleOAuthInterface { + UnifiedDI.resolve(GoogleOAuthInterface.self) ?? MockGoogleOAuthRepository() + } + public static var testValue: GoogleOAuthInterface = MockGoogleOAuthRepository() +} + +public extension DependencyValues { + var googleOAuthRepository: GoogleOAuthInterface { + get { self[GoogleOAuthRepositoryDependencyKey.self] } + set { self[GoogleOAuthRepositoryDependencyKey.self] = newValue } + } +} + diff --git a/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift new file mode 100644 index 0000000..348a97d --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift @@ -0,0 +1,50 @@ +// +// GoogleOAuthProviderInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 12/29/25. +// + +import Entity +import Foundation +import WeaveDI + +/// Google OAuth Provider Interface 프로토콜 +public protocol GoogleOAuthProviderInterface: Sendable { + func signInWithToken(token: String) async throws -> GoogleOAuthPayload +} + +/// Google OAuth Provider 의 DependencyKey 구조체 +public struct GoogleOAuthProviderDependency: DependencyKey { + public static var liveValue: GoogleOAuthProviderInterface { + UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() + } + + public static var testValue: GoogleOAuthProviderInterface { + UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() + } + + public static var previewValue: GoogleOAuthProviderInterface = testValue +} + +public extension DependencyValues { + var googleOAuthProvider: GoogleOAuthProviderInterface { + get { self[GoogleOAuthProviderDependency.self] } + set { self[GoogleOAuthProviderDependency.self] = newValue } + } +} + +/// 테스트용 Mock 구현체 +public struct MockGoogleOAuthProvider: GoogleOAuthProviderInterface { + public init() {} + + public func signInWithToken(token _: String) async throws -> GoogleOAuthPayload { + GoogleOAuthPayload( + idToken: "mock_google_id_token", + accessToken: "mock_google_access_token", + authorizationCode: "mock_google_auth_code", + displayName: "Mock Google User", + redirectUri: "https://picke.store/oauth/google" + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Google/MockGoogleOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Google/MockGoogleOAuthRepository.swift new file mode 100644 index 0000000..bf97792 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Google/MockGoogleOAuthRepository.swift @@ -0,0 +1,184 @@ +// +// MockGoogleOAuthRepository.swift +// DomainInterface +// +// Created by Wonji Suh on 12/29/25. +// + +import Entity +import Foundation + +public actor MockGoogleOAuthRepository: GoogleOAuthInterface { + public init() {} + + // MARK: - Configuration + + public enum Configuration { + case success + case failure + case customUser(String) + case networkError + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .customUser, .customDelay: + true + case .failure, .networkError: + false + } + } + + var delay: TimeInterval { + switch self { + case let .customDelay(delay): + delay + default: + 0.1 + } + } + + var mockUserName: String { + switch self { + case let .customUser(name): + name + default: + "Mock Google User" + } + } + } + + // MARK: - State + + private var configuration: Configuration = .success + private var signInCallCount = 0 + private var lastSignInCall: Date? + + // MARK: - Public Configuration Methods + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + signInCallCount = 0 + lastSignInCall = nil + } + + public func getSignInCallCount() -> Int { + signInCallCount + } + + public func getLastSignInCall() -> Date? { + lastSignInCall + } + + public func reset() { + configuration = .success + signInCallCount = 0 + lastSignInCall = nil + } + + // MARK: - GoogleOAuthRepositoryProtocol Implementation + + public func signIn() async throws -> GoogleOAuthPayload { + // Track call + signInCallCount += 1 + lastSignInCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed { + switch configuration { + case .failure: + throw MockGoogleOAuthError.signInFailed + case .networkError: + throw MockGoogleOAuthError.networkError + default: + throw MockGoogleOAuthError.unknownError + } + } + + // Return success payload + return GoogleOAuthPayload( + idToken: createMockIDToken(), + accessToken: createMockAccessToken(), + authorizationCode: createMockAuthCode(), + displayName: configuration.mockUserName, + redirectUri: "https://picke.store/oauth/google" + ) + } + + // MARK: - Private Helper Methods + + private func createMockIDToken() -> String { + "mock.google.idtoken.\(UUID().uuidString.prefix(8))" + } + + private func createMockAccessToken() -> String { + "ya29.mock-google-access-token-\(UUID().uuidString.prefix(12))" + } + + private func createMockAuthCode() -> String { + "4/mock-google-auth-code-\(UUID().uuidString.prefix(10))" + } +} + +// MARK: - Convenience Static Methods + +public extension MockGoogleOAuthRepository { + /// Creates a pre-configured actor for success scenario + static func success() -> MockGoogleOAuthRepository { + MockGoogleOAuthRepository(configuration: .success) + } + + /// Creates a pre-configured actor for failure scenario + static func failure() -> MockGoogleOAuthRepository { + MockGoogleOAuthRepository(configuration: .failure) + } + + /// Creates a pre-configured actor for custom user scenario + static func customUser(_ name: String) -> MockGoogleOAuthRepository { + MockGoogleOAuthRepository(configuration: .customUser(name)) + } + + /// Creates a pre-configured actor for network error scenario + static func networkError() -> MockGoogleOAuthRepository { + MockGoogleOAuthRepository(configuration: .networkError) + } + + /// Creates a pre-configured actor with custom delay + static func withDelay(_ delay: TimeInterval) -> MockGoogleOAuthRepository { + MockGoogleOAuthRepository(configuration: .customDelay(delay)) + } +} + +// MARK: - Mock Errors + +public enum MockGoogleOAuthError: Error, LocalizedError { + case signInFailed + case networkError + case invalidCredentials + case userCancelled + case unknownError + + public var errorDescription: String? { + switch self { + case .signInFailed: + "Mock Google OAuth sign in failed" + case .networkError: + "Mock Google OAuth network error" + case .invalidCredentials: + "Mock Google OAuth invalid credentials" + case .userCancelled: + "Mock Google OAuth user cancelled" + case .unknownError: + "Mock Google OAuth unknown error" + } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift new file mode 100644 index 0000000..c892a39 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift @@ -0,0 +1,53 @@ +// +// KakaoOAuthProviderInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 5/14/26. +// + +import Entity +import Foundation +import WeaveDI + +/// Kakao OAuth Provider Interface 프로토콜 +public protocol KakaoOAuthProviderInterface: Sendable { + func signInWithToken(token: String) async throws -> KakaoOAuthPayload +} + +/// Kakao OAuth Provider의 DependencyKey 구조체 +public struct KakaoOAuthProviderDependency: DependencyKey { + public static var liveValue: KakaoOAuthProviderInterface { + UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() + } + + public static var testValue: KakaoOAuthProviderInterface { + UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() + } + + public static var previewValue: KakaoOAuthProviderInterface = testValue +} + +/// DependencyValues extension으로 간편한 접근 제공 +public extension DependencyValues { + var kakaoOAuthProvider: KakaoOAuthProviderInterface { + get { self[KakaoOAuthProviderDependency.self] } + set { self[KakaoOAuthProviderDependency.self] = newValue } + } +} + +/// 테스트용 Mock 구현체 +public struct MockKakaoOAuthProvider: KakaoOAuthProviderInterface { + public init() {} + + public func signInWithToken(token _: String) async throws -> KakaoOAuthPayload { + KakaoOAuthPayload( + idToken: "mock_kakao_id_token", + accessToken: "mock_kakao_access_token", + refreshToken: "mock_kakao_refresh_token", + authorizationCode: "mock_kakao_auth_code", + displayName: "Mock Kakao User", + codeVerifier: "mock_kakao_code_verifier", + redirectUri: "mock://kakao/callback" + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift new file mode 100644 index 0000000..4d29635 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift @@ -0,0 +1,33 @@ +// +// KakaoOAuthRepositoryProtocol.swift +// Domain +// +// Created by Assistant on 12/4/25. +// + +import Dependencies +import Entity +import Foundation +import WeaveDI + +public protocol KakaoOAuthInterface: Sendable { + func signIn() async throws -> KakaoOAuthPayload +} + +// MARK: - Dependencies + +public struct KakaoOAuthRepositoryDependencyKey: DependencyKey { + public static var liveValue: KakaoOAuthInterface { + UnifiedDI.resolve(KakaoOAuthInterface.self) ?? MockKakaoOAuthRepository() + } + + public static var previewValue: KakaoOAuthInterface = MockKakaoOAuthRepository() + public static var testValue: KakaoOAuthInterface = MockKakaoOAuthRepository() +} + +public extension DependencyValues { + var kakaoOAuthRepository: KakaoOAuthInterface { + get { self[KakaoOAuthRepositoryDependencyKey.self] } + set { self[KakaoOAuthRepositoryDependencyKey.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/MockKakaoOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Kakao/MockKakaoOAuthRepository.swift new file mode 100644 index 0000000..68bc9b4 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Kakao/MockKakaoOAuthRepository.swift @@ -0,0 +1,122 @@ +// +// MockKakaoOAuthRepository.swift +// Domain +// +// Created by Assistant on 12/4/25. +// + +import Foundation +import Entity + +public actor MockKakaoOAuthRepository: KakaoOAuthInterface { + + public enum Configuration { + case success + case failure + case networkError + case customUser(String) + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .customUser, .customDelay: + return true + case .failure, .networkError: + return false + } + } + + var delay: TimeInterval { + switch self { + case .customDelay(let delay): + return delay + default: + return 0.1 + } + } + + var mockUserName: String { + switch self { + case .customUser(let name): + return name + default: + return "Mock Kakao User" + } + } + } + + private var configuration: Configuration + private var signInCallCount = 0 + private var lastSignInCall: Date? + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + signInCallCount = 0 + lastSignInCall = nil + } + + public func getSignInCallCount() -> Int { + signInCallCount + } + + public func getLastSignInCall() -> Date? { + lastSignInCall + } + + public func reset() { + configuration = .success + signInCallCount = 0 + lastSignInCall = nil + } + + public func signIn() async throws -> KakaoOAuthPayload { + signInCallCount += 1 + lastSignInCall = Date() + + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + guard configuration.shouldSucceed else { + switch configuration { + case .failure: + throw MockKakaoOAuthError.signInFailed + case .networkError: + throw MockKakaoOAuthError.networkError + default: + throw MockKakaoOAuthError.unknownError + } + } + + return KakaoOAuthPayload( + idToken: "mock.kakao.idtoken.\(UUID().uuidString.prefix(8))", + accessToken: "mock-kakao-access-token-\(UUID().uuidString.prefix(12))", + refreshToken: "mock-kakao-refresh-token", + authorizationCode: "mock-kakao-auth-code-\(UUID().uuidString.prefix(8))", + displayName: configuration.mockUserName, + codeVerifier: "mock-kakao-code-verifier-\(UUID().uuidString.prefix(8))", + redirectUri: "https://sseudam.up.railway.app/api/v1/oauth/kakao/callback" + ) + } +} + +public enum MockKakaoOAuthError: Error, LocalizedError { + case signInFailed + case networkError + case unknownError + + public var errorDescription: String? { + switch self { + case .signInFailed: + return "Mock Kakao OAuth sign in failed" + case .networkError: + return "Mock Kakao OAuth network error" + case .unknownError: + return "Mock Kakao OAuth unknown error" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Auth/AuthExitEntity.swift b/Projects/Domain/Entity/Sources/Auth/AuthExitEntity.swift new file mode 100644 index 0000000..fcb93b4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Auth/AuthExitEntity.swift @@ -0,0 +1,27 @@ +// +// AuthExitEntity.swift +// Entity +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct AuthExitEntity: Equatable { + public let loggedOut: Bool + public let code: String? + public let message: String? + public let detail: String? + + public init( + loggedOut: Bool = false, + code: String? = nil, + message: String? = nil, + detail: String? = nil + ) { + self.loggedOut = loggedOut + self.code = code + self.message = message + self.detail = detail + } +} diff --git a/Projects/Domain/Entity/Sources/Auth/SocialType.swift b/Projects/Domain/Entity/Sources/Auth/SocialType.swift new file mode 100644 index 0000000..dd3bc4d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Auth/SocialType.swift @@ -0,0 +1,49 @@ +// +// SocialType.swift +// Entity +// +// Created by Wonji Suh on 5/14/26. +// + +public enum SocialType: String, CaseIterable, Identifiable, Hashable { + case kakao + case apple + case google + + public var id: String { rawValue } + + var description: String { + switch self { + case .kakao: + "kakao" + case .apple: + "Apple" + case .google: + "Google" + } + } + + public var image: String { + switch self { + case .kakao: + "kakao" + case .apple: + "apple.logo" + case .google: + "google" + } + } + + /// 백엔드 OAuth code 교환에 사용되는 redirect URI. + /// 카카오/구글 authorize URL 에 그대로 사용한 값과 동일해야 한다. + public var redirectUri: String { + switch self { + case .kakao: + "https://picke.store/oauth/kakao" + case .google: + "https://picke.store/oauth/google" + case .apple: + "" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift b/Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift new file mode 100644 index 0000000..6114742 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift @@ -0,0 +1,30 @@ +// +// WithdrawEntity.swift +// Entity +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct WithdrawEntity: Equatable { + public let isSuccess: Bool + public let withdrawn: Bool + public let code: String? + public let message: String? + public let detail: String? + + public init( + isSuccess: Bool, + withdrawn: Bool? = nil, + code: String? = nil, + message: String? = nil, + detail: String? = nil + ) { + self.isSuccess = isSuccess + self.withdrawn = withdrawn ?? isSuccess + self.code = code + self.message = message + self.detail = detail + } +} diff --git a/Projects/Domain/Entity/Sources/Error/AuthError.swift b/Projects/Domain/Entity/Sources/Error/AuthError.swift new file mode 100644 index 0000000..1c94b92 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/AuthError.swift @@ -0,0 +1,117 @@ +// +// AuthError.swift +// Entity +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +public enum AuthError: Error, Equatable, LocalizedError, Hashable { + /// 설정 누락 (Google/Supabase 키 등) + case configurationMissing + /// 프레젠트할 컨트롤러가 없음 + case missingPresentingController + /// ID 토큰 없음 + case missingIDToken + /// 사용자가 로그인 플로우를 취소한 경우 + case userCancelled + /// 자격 증명 문제 (예: 잘못된 nonce, credential 등) + case invalidCredential(String) + /// 네트워크/통신 문제 + case networkError(String) + /// Supabase나 백엔드 쪽에서 온 에러 + case backendError(String) + /// 약관 동의가 필요한 경우 + case needsTermsAgreement(String) + /// 회원 탈퇴 실패 + case accountDeletionFailed + /// 회원 탈퇴 권한 없음 + case accountDeletionNotAllowed + /// 이미 탈퇴된 계정 + case accountAlreadyDeleted + /// refresh token이 만료된 경우 + case refreshTokenExpired + /// 그 외 알 수 없는 에러 + case unknownError(String) + + // MARK: - LocalizedError + + public var errorDescription: String? { + switch self { + case .configurationMissing: + return "인증 설정이 올바르게 구성되지 않았습니다." + case .missingPresentingController: + return "프레젠트할 뷰 컨트롤러를 찾을 수 없습니다." + case .missingIDToken: + return "ID 토큰을 가져오지 못했습니다." + case .userCancelled: + return "사용자가 로그인을 취소했습니다." + case .invalidCredential(let message): + return "잘못된 자격 증명입니다: \(message)" + case .networkError(let message): + return "네트워크 오류가 발생했습니다: \(message)" + case .backendError(let message): + return "서버에서 오류가 발생했습니다: \(message)" + case .needsTermsAgreement(let message): + return "\(message)" + case .accountDeletionFailed: + return "회원 탈퇴에 실패했습니다." + case .accountDeletionNotAllowed: + return "회원 탈퇴 권한이 없습니다." + case .accountAlreadyDeleted: + return "이미 탈퇴된 계정입니다." + case .refreshTokenExpired: + return "로그인이 만료되었습니다. 다시 로그인해주세요." + case .unknownError(let message): + return "알 수 없는 오류가 발생했습니다: \(message)" + } + } +} + +// MARK: - Convenience Methods + +public extension AuthError { + static func from(_ error: Error) -> AuthError { + if let authError = error as? AuthError { + return authError + } + return .unknownError(error.localizedDescription) + } + + var isNetworkError: Bool { + switch self { + case .networkError: + return true + default: + return false + } + } + + var isRetryable: Bool { + switch self { + case .networkError, .backendError: + return true + default: + return false + } + } + + var isAccountDeletionError: Bool { + switch self { + case .accountDeletionFailed, .accountDeletionNotAllowed, .accountAlreadyDeleted: + return true + default: + return false + } + } + + var isTokenExpiredError: Bool { + switch self { + case .refreshTokenExpired: + return true + default: + return false + } + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/AppleOAuthPayload.swift b/Projects/Domain/Entity/Sources/OAuth/AppleOAuthPayload.swift new file mode 100644 index 0000000..e91f38c --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/AppleOAuthPayload.swift @@ -0,0 +1,27 @@ +// +// AppleOAuthPayload.swift +// Entity +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +public struct AppleOAuthPayload { + public let idToken: String + public let authorizationCode: String? + public let displayName: String? + public let nonce: String + + public init( + idToken: String, + authorizationCode: String?, + displayName: String?, + nonce: String, + ) { + self.idToken = idToken + self.authorizationCode = authorizationCode + self.displayName = displayName + self.nonce = nonce + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/AuthToken.swift b/Projects/Domain/Entity/Sources/OAuth/AuthToken.swift new file mode 100644 index 0000000..ba811ac --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/AuthToken.swift @@ -0,0 +1,24 @@ +// +// AuthToken.swift +// Entity +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +public struct AuthTokens: Equatable, Hashable { + public let accessToken: String + public let refreshToken: String + public let oauthRefreshToken: String? + + public init( + accessToken: String, + refreshToken: String, + oauthRefreshToken: String? = nil + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.oauthRefreshToken = oauthRefreshToken + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/GoogleOAuthPayload.swift b/Projects/Domain/Entity/Sources/OAuth/GoogleOAuthPayload.swift new file mode 100644 index 0000000..d79e4df --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/GoogleOAuthPayload.swift @@ -0,0 +1,44 @@ +// +// GoogleOAuthPayload.swift +// Entity +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +/// Google OAuth 콜백에서 받은 백엔드 로그인 결과 +/// 백엔드가 redirect_uri 를 직접 처리하고 picke:// 딥링크에 토큰을 실어 보내는 흐름. +public struct GoogleOAuthPayload { + public let idToken: String + public let accessToken: String? + public let refreshToken: String? + public let authorizationCode: String? + public let displayName: String? + public let userTag: String? + public let status: String? + public let isNewUser: Bool + public let redirectUri: String? + + public init( + idToken: String, + accessToken: String?, + refreshToken: String? = nil, + authorizationCode: String? = nil, + displayName: String? = nil, + userTag: String? = nil, + status: String? = nil, + isNewUser: Bool = false, + redirectUri: String? = nil + ) { + self.idToken = idToken + self.accessToken = accessToken + self.refreshToken = refreshToken + self.authorizationCode = authorizationCode + self.displayName = displayName + self.userTag = userTag + self.status = status + self.isNewUser = isNewUser + self.redirectUri = redirectUri + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/KakaoOAuthPayload.swift b/Projects/Domain/Entity/Sources/OAuth/KakaoOAuthPayload.swift new file mode 100644 index 0000000..2c73cbb --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/KakaoOAuthPayload.swift @@ -0,0 +1,47 @@ +// +// KakaoOAuthPayload.swift +// Domain +// +// Created by Assistant on 12/4/25. +// + +import Foundation + +/// Kakao OAuth 콜백에서 받은 백엔드 로그인 결과 +/// 백엔드가 redirect_uri 를 직접 처리하고 picke:// 딥링크에 토큰을 실어 보내는 흐름. +public struct KakaoOAuthPayload { + public let idToken: String + public let accessToken: String + public let refreshToken: String? + public let authorizationCode: String? + public let displayName: String? + public let codeVerifier: String? + public let redirectUri: String? + public let userTag: String? + public let status: String? + public let isNewUser: Bool + + public init( + idToken: String, + accessToken: String, + refreshToken: String? = nil, + authorizationCode: String? = nil, + displayName: String? = nil, + codeVerifier: String? = nil, + redirectUri: String? = nil, + userTag: String? = nil, + status: String? = nil, + isNewUser: Bool = false + ) { + self.idToken = idToken + self.accessToken = accessToken + self.refreshToken = refreshToken + self.authorizationCode = authorizationCode + self.displayName = displayName + self.codeVerifier = codeVerifier + self.redirectUri = redirectUri + self.userTag = userTag + self.status = status + self.isNewUser = isNewUser + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift b/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift new file mode 100644 index 0000000..ae24611 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift @@ -0,0 +1,33 @@ +// +// LoginEntity.swift +// Entity +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation + +public struct LoginEntity: Equatable { + public let name: String + public let provider: SocialType + public let token: AuthTokens + public let isNewUser: Bool + public let userTag: String + public let status: String + + public init( + name: String, + isNewUser: Bool, + provider: SocialType, + token: AuthTokens, + userTag: String = "", + status: String = "" + ) { + self.name = name + self.isNewUser = isNewUser + self.provider = provider + self.token = token + self.userTag = userTag + self.status = status + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift new file mode 100644 index 0000000..5efef4a --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift @@ -0,0 +1,38 @@ +// +// UserSession.swift +// Entity +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +public struct UserSession: Equatable { + public var name: String + public var provider: SocialType + public var token: String + public var accessToken: String + public var oauthRefreshToken: String? + + public init( + name: String = "", + provider: SocialType = .apple, + token: String = "", + accessToken: String = "", + oauthRefreshToken: String? = nil, + inviteCode: String = "", + generation: String = "" + ) { + self.name = name + self.provider = provider + self.token = token + self.accessToken = accessToken + self.oauthRefreshToken = oauthRefreshToken + } + +} + + +public extension UserSession { + static let empty = UserSession() +} diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift new file mode 100644 index 0000000..13ea077 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -0,0 +1,86 @@ +// +// AuthUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 5/14/26. +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture +import WeaveDI + +public struct AuthUseCaseImpl: AuthInterface { + @Dependency(\.authRepository) var authRepository + @Dependency(\.keychainManager) private var keychainManager: KeychainManaging + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + + public init() {} + + // MARK: - 로그인 + + public func login( + provider: SocialType, + authorizationCode: String, + redirectUri: String + ) async throws -> LoginEntity { + let result = try await authRepository.login( + provider: provider, + authorizationCode: authorizationCode, + redirectUri: redirectUri + ) + + $userSession.withLock { + $0.accessToken = result.token.accessToken + $0.oauthRefreshToken = result.token.oauthRefreshToken + $0.provider = result.provider + $0.name = result.name + } + keychainManager.save( + accessToken: result.token.accessToken, + refreshToken: result.token.refreshToken + ) + authRepository.updateSessionCredential(with: result.token) + + return result + } + + public func refresh() async throws -> AuthTokens { + let tokens = try await authRepository.refresh() + keychainManager.save(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) + authRepository.updateSessionCredential(with: tokens) + return tokens + } + + public func logout() async throws -> AuthExitEntity { + let result = try await authRepository.logout() + keychainManager.clear() + return result + } + + public func withDraw(token: String) async throws -> WithdrawEntity { + let result = try await authRepository.withDraw(token: token) + keychainManager.clear() + return result + } + + public func updateSessionCredential(with tokens: AuthTokens) { + authRepository.updateSessionCredential(with: tokens) + } +} + +extension AuthUseCaseImpl: DependencyKey { + public static var liveValue = AuthUseCaseImpl() + public static var testValue = AuthUseCaseImpl() + public static var previewValue = AuthUseCaseImpl() +} + +public extension DependencyValues { + var authUseCase: AuthUseCaseImpl { + get { self[AuthUseCaseImpl.self] } + set { self[AuthUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift index b0ba267..8655a6e 100644 --- a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift +++ b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift @@ -20,7 +20,7 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { static let refreshToken = "REFRESH_TOKEN" } - public init(service: String = "io.dddstudy.attendance") { + public init(service: String = "io.Picke.co") { self.service = service } diff --git a/Projects/Domain/UseCase/Sources/OAuth/Dependencies+OAuth.swift b/Projects/Domain/UseCase/Sources/OAuth/Dependencies+OAuth.swift new file mode 100644 index 0000000..5681f92 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/OAuth/Dependencies+OAuth.swift @@ -0,0 +1,34 @@ +// +// Dependencies+OAuth.swift +// UseCase +// +// Created by Wonji Suh on 12/29/25. +// + +import Dependencies +import DomainInterface +import Foundation + +// MARK: - Apple OAuth Provider Registration + +public extension AppleOAuthProviderDependency { + static var liveValue: AppleOAuthProviderInterface { + AppleOAuthProvider() + } +} + +// MARK: - Google OAuth Provider Registration + +public extension GoogleOAuthProviderDependency { + static var liveValue: GoogleOAuthProviderInterface { + GoogleOAuthProvider() + } +} + +// MARK: - Kakao OAuth Provider Registration + +public extension KakaoOAuthProviderDependency { + static var liveValue: KakaoOAuthProviderInterface { + KakaoOAuthProvider() + } +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift new file mode 100644 index 0000000..6c979ee --- /dev/null +++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift @@ -0,0 +1,43 @@ +// +// AppleOAuthProvider.swift +// UseCase +// +// Created by Wonji Suh on 12/29/25. +// + +import Foundation +import Dependencies +import LogMacro +import AuthenticationServices +@preconcurrency import Entity +import DomainInterface +import Sharing + +public final class AppleOAuthProvider: AppleOAuthProviderInterface, @unchecked Sendable { + @Dependency(\.appleOAuthRepository) private var appleRepository: AppleOAuthInterface + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + public init() {} + + public func signInWithCredential( + credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload { + let payload = try await appleRepository.signInWithCredential(credential, nonce: nonce) + Log.info("Apple sign-in completed through repository with credential") + return payload + } + + public func signIn() async throws -> AppleOAuthPayload { + let payload = try await appleRepository.signIn() + Log.info("Apple sign-in completed through repository (direct)") + return payload + } + + private func formatDisplayName(_ components: PersonNameComponents?) -> String? { + guard let components else { return nil } + let formatter = PersonNameComponentsFormatter() + let name = formatter.string(from: components).trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? nil : name + } +} + diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift new file mode 100644 index 0000000..60547ae --- /dev/null +++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift @@ -0,0 +1,27 @@ +// +// GoogleOAuthProvider.swift +// UseCase +// +// Created by Wonji Suh on 12/29/25. +// + +import Dependencies +import DomainInterface +@preconcurrency import Entity +import Foundation +import LogMacro +import Sharing + +public final class GoogleOAuthProvider: GoogleOAuthProviderInterface, @unchecked Sendable { + @Dependency(\.googleOAuthRepository) private var googleRepository: GoogleOAuthInterface + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + public init() {} + + public func signInWithToken(token _: String) async throws -> GoogleOAuthPayload { + Log.info("Starting Google OAuth flow") + let payload = try await googleRepository.signIn() + $userSession.withLock { $0.accessToken = payload.accessToken ?? "" } + Log.debug("google authCode", payload.authorizationCode) + return payload + } +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift new file mode 100644 index 0000000..af993ea --- /dev/null +++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift @@ -0,0 +1,26 @@ +// +// KakaoOAuthProvider.swift +// UseCase +// +// Created by Wonji Suh on 5/14/26. +// + +import Dependencies +import DomainInterface +@preconcurrency import Entity +import Foundation +import LogMacro +import Sharing + +public final class KakaoOAuthProvider: KakaoOAuthProviderInterface, @unchecked Sendable { + @Dependency(\.kakaoOAuthRepository) private var kakaoRepository: KakaoOAuthInterface + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + public init() {} + + public func signInWithToken(token _: String) async throws -> KakaoOAuthPayload { + Log.info("Starting Kakao OAuth flow") + let payload = try await kakaoRepository.signIn() + $userSession.withLock { $0.accessToken = payload.accessToken } + return payload + } +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift new file mode 100644 index 0000000..a3355ca --- /dev/null +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -0,0 +1,195 @@ +// +// UnifiedOAuthUseCase.swift +// UseCase +// +// Created by Wonji Suh on 12/29/25. +// + +import AuthenticationServices +import Dependencies +import DomainInterface +@preconcurrency import Entity +import Foundation +import LogMacro +import Sharing + +/// 통합 OAuth UseCase — 소셜 인증 → 백엔드 로그인까지 단일 진입점 +public struct UnifiedOAuthUseCase { + @Dependency(\.authRepository) private var authRepository: AuthInterface + @Dependency(\.appleOAuthProvider) private var appleProvider: AppleOAuthProviderInterface + @Dependency(\.googleOAuthProvider) private var googleProvider: GoogleOAuthProviderInterface + @Dependency(\.kakaoOAuthProvider) private var kakaoProvider: KakaoOAuthProviderInterface + @Dependency(\.keychainManager) private var keychainManager: KeychainManaging + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("appleUserName")) var savedAppleUserName: String? + + public init() {} +} + +// MARK: - Public Interface + +public extension UnifiedOAuthUseCase { + /// 통합 소셜 로그인 처리 + func socialLogin( + with socialType: SocialType, + appleCredential: ASAuthorizationAppleIDCredential? = nil, + nonce: String? = nil, + googleToken: String? = nil, + kakaoToken: String? = nil + ) async throws -> LoginEntity { + switch socialType { + case .apple: + guard let credential = appleCredential, let nonce else { + throw AuthError.invalidCredential("Apple 로그인에 필요한 credential 또는 nonce가 없습니다") + } + return try await appleLogin(credential: credential, nonce: nonce) + + case .google: + guard let token = googleToken else { + throw AuthError.invalidCredential("Google 로그인에 필요한 token이 없습니다") + } + return try await googleLogin(token: token) + + case .kakao: + guard let token = kakaoToken else { + throw AuthError.invalidCredential("Kakao 로그인에 필요한 token이 없습니다") + } + return try await kakaoLogin(token: token) + } + } + + /// Apple 로그인 처리 + func appleLogin( + credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> LoginEntity { + let payload = try await appleProvider.signInWithCredential( + credential: credential, + nonce: nonce + ) + Log.debug("apple authcode", payload.authorizationCode) + + let userName: String = { + if let displayName = payload.displayName, !displayName.isEmpty { + self.$savedAppleUserName.withLock { $0 = displayName } + return displayName + } else { + return self.savedAppleUserName ?? "" + } + }() + + $userSession.withLock { + $0.token = payload.authorizationCode ?? "" + $0.accessToken = payload.idToken + $0.oauthRefreshToken = payload.idToken + $0.name = userName + } + + let loginEntity = try await authRepository.login( + provider: .apple, + authorizationCode: payload.authorizationCode ?? "", + redirectUri: SocialType.apple.redirectUri + ) + + keychainManager.save( + accessToken: loginEntity.token.accessToken, + refreshToken: loginEntity.token.refreshToken + ) + authRepository.updateSessionCredential(with: loginEntity.token) + + return loginEntity + } + + /// Google 로그인 처리 — picke:// 콜백에서 받은 `code` 를 백엔드에 전달. + func googleLogin( + token: String + ) async throws -> LoginEntity { + let payload = try await googleProvider.signInWithToken(token: token) + Log.debug("google authorizationCode", payload.authorizationCode) + + $userSession.withLock { + $0.token = payload.authorizationCode ?? "" + $0.name = payload.displayName ?? "" + $0.provider = .google + } + + let loginEntity = try await authRepository.login( + provider: .google, + authorizationCode: payload.authorizationCode ?? "", + redirectUri: payload.redirectUri ?? SocialType.google.redirectUri + ) + + keychainManager.save( + accessToken: loginEntity.token.accessToken, + refreshToken: loginEntity.token.refreshToken + ) + authRepository.updateSessionCredential(with: loginEntity.token) + + return loginEntity + } + + /// Kakao 로그인 처리 — picke:// 콜백에서 받은 `code` 를 백엔드에 전달. + func kakaoLogin(token: String) async throws -> LoginEntity { + let payload = try await kakaoProvider.signInWithToken(token: token) + Log.debug("kakao authorizationCode", payload.authorizationCode) + + $userSession.withLock { + $0.token = payload.authorizationCode ?? "" + $0.name = payload.displayName ?? "" + $0.provider = .kakao + } + + let loginEntity = try await authRepository.login( + provider: .kakao, + authorizationCode: payload.authorizationCode ?? "", + redirectUri: payload.redirectUri ?? SocialType.kakao.redirectUri + ) + + keychainManager.save( + accessToken: loginEntity.token.accessToken, + refreshToken: loginEntity.token.refreshToken + ) + authRepository.updateSessionCredential(with: loginEntity.token) + + return loginEntity + } + + /// OAuth 플로우 처리 (TCA용) + func processOAuthFlow( + with socialType: SocialType, + appleCredential: ASAuthorizationAppleIDCredential? = nil, + nonce: String? = nil, + googleToken: String? = nil, + kakaoToken: String? = nil + ) async -> Result { + do { + let result = try await socialLogin( + with: socialType, + appleCredential: appleCredential, + nonce: nonce, + googleToken: googleToken, + kakaoToken: kakaoToken + ) + return .success(result) + } catch let error as AuthError { + return .failure(error) + } catch { + return .failure(.unknownError(error.localizedDescription)) + } + } +} + +// MARK: - Dependencies Registration + +extension UnifiedOAuthUseCase: DependencyKey { + public static let liveValue = UnifiedOAuthUseCase() + public static let testValue = UnifiedOAuthUseCase() + public static let previewValue = UnifiedOAuthUseCase() +} + +public extension DependencyValues { + var unifiedOAuthUseCase: UnifiedOAuthUseCase { + get { self[UnifiedOAuthUseCase.self] } + set { self[UnifiedOAuthUseCase.self] = newValue } + } +} diff --git a/Projects/Presentation/Auth/Project.swift b/Projects/Presentation/Auth/Project.swift new file mode 100644 index 0000000..497cafe --- /dev/null +++ b/Projects/Presentation/Auth/Project.swift @@ -0,0 +1,19 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Auth", + bundleId: .appBundleID(name: ".Auth"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .SPM.composableArchitecture, + .SPM.tcaFlow, + .Domain(implements: .UseCase), + .Shared(implements: .Shared), + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift new file mode 100644 index 0000000..02a70fa --- /dev/null +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -0,0 +1,152 @@ +// +// AuthCoordinator.swift +// Auth +// +// Created by Wonji Suh on 5/11/26. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +import Entity + +@FlowCoordinator(screen: "AuthScreen", navigation: true) +public struct AuthCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + var routes: [Route] + + public init() { + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + self.routes = [.root(.login(.init(userSession: userSession)), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } + + // MARK: - ViewAction + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + // MARK: - AsyncAction 비동기 처리 액션 + + public enum AsyncAction: Equatable { + + } + + // MARK: - 앱내에서 사용하는 액션 + + public enum InnerAction: Equatable { + + } + + // MARK: - NavigationAction + + public enum NavigationAction: Equatable { + + } + + func handleRoute(state: inout State, action: Action) -> Effect { + switch action { + case .router(let routeAction): + return routerAction(state: &state, action: routeAction) + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .navigation(let navigationAction): + return handleNavigationAction(state: &state, action: navigationAction) + } + } +} + +// MARK: - Effect Cancellation IDs +nonisolated enum AuthCancelID: Hashable { + case loginEffects +} + +extension AuthCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + + // MARK: - 초대코드 입력 + + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } + + private func handleNavigationAction( + state: inout State, + action: NavigationAction + ) -> Effect { + switch action { + + + + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + return .none + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + return .none + } +} + +extension AuthCoordinator { + @Reducer + public enum AuthScreen { + case login(LoginFeature) + } +} + +extension AuthCoordinator.AuthScreen.State: Equatable {} diff --git a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift new file mode 100644 index 0000000..0030751 --- /dev/null +++ b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift @@ -0,0 +1,36 @@ +// +// AuthCoordinatorView.swift +// Auth +// +// Created by Wonji Suh on 5/11/26. +// + +import Foundation + +import SwiftUI + +import TCAFlow +import ComposableArchitecture + + +public struct AuthCoordinatorView: View { + @Bindable private var store: StoreOf + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case .login(let loginStore): + LoginView(store: loginStore) + .navigationBarBackButtonHidden() + + + } + } + } +} diff --git a/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift new file mode 100644 index 0000000..0a410ef --- /dev/null +++ b/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift @@ -0,0 +1,222 @@ +// +// LoginFeature.swift +// Auth +// +// Created by Wonji Suh on 5/11/26. +// + +import AuthenticationServices +import Foundation + +import ComposableArchitecture +import LogMacro + +import DesignSystem +import Entity +import UseCase + +@Reducer +public struct LoginFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + var currentSocialType: SocialType? + var nonce: String = "" + var appleAccessToken: String = "" + var appleLoginFullName: ASAuthorizationAppleIDCredential? + @Shared var userSession: UserSession + var loginEntity: LoginEntity? + + public init( + userSession: UserSession = .empty + ) { + _userSession = Shared(wrappedValue: userSession, .inMemory("UserSession")) + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + // MARK: - ViewAction + + @CasePathable + public enum View { + case signInWithSocial(social: SocialType) + } + + // MARK: - AsyncAction 비동기 처리 액션 + + public enum AsyncAction { + case prepareAppleRequest(ASAuthorizationAppleIDRequest) + case appleLogin(Result, nonce: String) + case login(socialType: SocialType) + } + + // MARK: - 앱내에서 사용하는 액션 + + public enum InnerAction: Equatable { + case loginResponse(Result) + } + + // MARK: - NavigationAction + + public enum DelegateAction: Equatable {} + + nonisolated enum CancelID: Hashable { + case googleOAuth + case appleOAuth + case kakaoOAuth + } + + @Dependency(\.appleManger) var appleLoginManger + @Dependency(\.unifiedOAuthUseCase) var unifiedOAuthUseCase + @Dependency(\.continuousClock) var clock + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + .none + + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension LoginFeature { + private func handleViewAction( + state _: inout State, + action: View + ) -> Effect { + switch action { + case let .signInWithSocial(social): + .send(.async(.login(socialType: social))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .prepareAppleRequest(request): + let nonce = appleLoginManger.prepare(request) + state.nonce = nonce + return .none + + case let .appleLogin(result, nonce): + state.currentSocialType = .apple + return .run { send in + guard + case let .success(auth) = result, + let credential = auth.credential as? ASAuthorizationAppleIDCredential, + !nonce.isEmpty + else { + await send(.inner(.loginResponse(.failure(.invalidCredential("Apple 인증 정보가 없습니다"))))) + return + } + + // Apple credential을 직접 처리하여 로그인 완료 + let outcome = await unifiedOAuthUseCase.processOAuthFlow( + with: .apple, + appleCredential: credential, + nonce: nonce, + googleToken: nil + ) + await send(.inner(.loginResponse(outcome))) + } + .cancellable(id: CancelID.appleOAuth) + + case let .login(socialType): + state.currentSocialType = socialType + state.$userSession.withLock { $0.provider = socialType } + return .run { [ + appleCredential = state.appleLoginFullName, + nonce = state.nonce + ] send in + // Google/Kakao Provider 는 실제 토큰 대신 트리거 문자열만 받음 (내부에서 OAuth 플로우를 시작). + let outcome = await unifiedOAuthUseCase.processOAuthFlow( + with: socialType, + appleCredential: appleCredential, + nonce: nonce, + googleToken: socialType == .google ? "google" : nil, + kakaoToken: socialType == .kakao ? "kakao" : nil + ) + return await send(.inner(.loginResponse(outcome))) + } + .cancellable(id: { + switch socialType { + case .apple: CancelID.appleOAuth + case .google: CancelID.googleOAuth + case .kakao: CancelID.kakaoOAuth + } + }()) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .loginResponse(result): + switch result { + case let .success(loginEntity): + state.loginEntity = loginEntity + return .none + +// if loginEntity.isNewUser { +// return .send(.view(.showPolicyPopUp)) +// } else if state.userSession.userRole == .manager { +// return .send(.navigation(.presentStaffMain)) +// } else { +// return .send(.navigation(.presentMemberMain)) +// } + + case let .failure(error): + #logNetwork("로그인 실패", error.localizedDescription) + let socialType = state.currentSocialType + return .run { _ in + await MainActor.run { + let errorMessage = switch socialType { + case .apple: + "Apple 인증에 실패하였습니다." + case .google: + "구글 인증에 실패하였습니다." + case .kakao: + "카카오 인증에 실패하였습니다." + default: + "인증에 실패했어요. 다시 시도해주세요." + } + ToastManager.shared.showError(errorMessage) + } + } + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action {} + } +} diff --git a/Projects/Presentation/Auth/Sources/View/Components/SocialCircleButtonView.swift b/Projects/Presentation/Auth/Sources/View/Components/SocialCircleButtonView.swift new file mode 100644 index 0000000..7d20a5b --- /dev/null +++ b/Projects/Presentation/Auth/Sources/View/Components/SocialCircleButtonView.swift @@ -0,0 +1,78 @@ +// +// SocialCircleButtonView.swift +// Auth +// +// Created by Wonji Suh on 5/14/26. +// + +import SwiftUI +import AuthenticationServices +import ComposableArchitecture +import Entity + +struct SocialCircleButtonView: View { + @State var store: StoreOf + let type: SocialType + let onTap: () -> Void + + private let circleSize: CGFloat = 72 + + @ViewBuilder + var body: some View { + switch type { + case .apple: + ZStack { + Circle() + .fill(.black) + .frame(width: circleSize, height: circleSize) + + Image(systemName: type.image) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(.white) + + SignInWithAppleButton(.signIn) { request in + store.send(.async(.prepareAppleRequest(request))) + } onCompletion: { result in + store.send(.async(.appleLogin(result, nonce: store.nonce))) + } + .frame(width: circleSize, height: circleSize) + .clipShape(Circle()) + .opacity(0.02) + .allowsHitTesting(true) + } + + case .google: + Button(action: onTap) { + Circle() + .fill(.white) + .overlay(Circle().stroke(.gray50, lineWidth: 1)) + .frame(width: circleSize, height: circleSize) + .overlay( + Image(assetName: type.image) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + ) + } + .buttonStyle(.plain) + + case .kakao: + Button(action: onTap) { + Circle() + .fill(.clear) + .overlay(Circle().stroke(.gray50, lineWidth: 1)) + .frame(width: circleSize, height: circleSize) + .overlay( + Image(assetName: type.image) + .resizable() + .scaledToFit() + .frame(width: circleSize, height: circleSize) + ) + } + .buttonStyle(.plain) + + } + } +} diff --git a/Projects/Presentation/Auth/Sources/View/LoginView.swift b/Projects/Presentation/Auth/Sources/View/LoginView.swift new file mode 100644 index 0000000..418bd22 --- /dev/null +++ b/Projects/Presentation/Auth/Sources/View/LoginView.swift @@ -0,0 +1,96 @@ +// +// LoginView.swift +// Auth +// +// Created by Wonji Suh on 5/11/26. +// + +import SwiftUI +import ComposableArchitecture + +import DesignSystem +import Entity + +public struct LoginView : View { + @Bindable var store: StoreOf + + + + public var body: some View { + ZStack { + Color.neutral50 + .edgesIgnoringSafeArea(.all) + + VStack { + logoView() + + Spacer() + .frame(height: 200) + + loginSNSButtonText() + + logjnButton() + + Spacer() + .frame(height: UIScreen.screenHeight * 0.12) + } + .toastOverlay() + } + } +} + + +extension LoginView { + @ViewBuilder + private func logoView() -> some View { + VStack(alignment: .center) { + Spacer() + + Text(" 당신의 생각을") + .pretendardCustomFont(textStyle: .headingMedium) + .foregroundStyle(.neutral200) + + Image(asset: .loginLogo) + .resizable() + .scaledToFit() + .frame(width: 106, height: 90) + } + } + + @ViewBuilder + private func loginSNSButtonText() -> some View { + HStack { + Rectangle() + .fill(.borderGray) + .frame(width: 64, height: 1) + + Spacer() + .frame(width: 12) + + Text("SNS 계정으로 로그인") + .pretendardFont(family: .Medium, size: 15) + .foregroundStyle(.neutral300) + + + Rectangle() + .fill(.borderGray) + .frame(width: 64, height: 1) + + } + } + + @ViewBuilder + private func logjnButton() -> some View { + HStack(alignment: .center, spacing: 32) { + ForEach(SocialType.allCases) { type in + SocialCircleButtonView( + store: store, + type: type + ) { + store.send(.view(.signInWithSocial(social: type))) + } + } + } + .padding(.top, 32) + } +} diff --git a/Projects/Presentation/Auth/Tests/Sources/AuthTests.swift b/Projects/Presentation/Auth/Tests/Sources/AuthTests.swift new file mode 100644 index 0000000..f664af6 --- /dev/null +++ b/Projects/Presentation/Auth/Tests/Sources/AuthTests.swift @@ -0,0 +1,27 @@ +// +// AuthTests.swift +// Presentation.AuthTests +// +// Created by Roy on 2026-05-09. +// + +import Testing +@testable import Auth + +struct AuthTests { + + @Test + func authExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func authLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Presentation/Presentation/Project.swift b/Projects/Presentation/Presentation/Project.swift index 3d5bff6..1468470 100644 --- a/Projects/Presentation/Presentation/Project.swift +++ b/Projects/Presentation/Presentation/Project.swift @@ -10,7 +10,8 @@ let project = Project.makeModule( product: .staticFramework, settings: .settings(), dependencies: [ - .Presentation(implements: .Splash) + .Presentation(implements: .Splash), + .Presentation(implements: .Auth) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift index 85cc0af..838e792 100644 --- a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift +++ b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift @@ -8,3 +8,4 @@ // MARK: - 여기에 한번에 호출 할꺼 추가 @_exported import Splash +@_exported import Auth diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift index 121751f..288e82d 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift @@ -5,7 +5,6 @@ // Created by Wonji Suh on 5/6/26. // - import Foundation import ComposableArchitecture @@ -15,6 +14,7 @@ public struct SplashFeature { @ObservableState public struct State: Equatable { + var loading: Bool = true @@ -35,7 +35,7 @@ public struct SplashFeature { // MARK: - ViewAction @CasePathable public enum View { - + case onAppear } // MARK: - AsyncAction 비동기 처리 액션 @@ -60,9 +60,7 @@ public struct SplashFeature { } - - - + @Dependency(\.continuousClock) var clock public var body: some Reducer { BindingReducer() @@ -96,6 +94,10 @@ extension SplashFeature { action: View ) -> Effect { switch action { + case .onAppear: + return .run { send in + try await clock.sleep(for: .seconds(0.3)) + } } } diff --git a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift new file mode 100644 index 0000000..496e132 --- /dev/null +++ b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift @@ -0,0 +1,41 @@ +// +// SplashLogoAnimatedImageView.swift +// Splash +// +// Created by Wonji Suh on 5/14/26. +// + +import SwiftUI +import SDWebImage + +struct SplashLogoAnimatedImageView: UIViewRepresentable { + private static let size = CGSize(width: 250, height: 250) + private static let image = SDAnimatedImage(named: "splashLogo.gif") + + func makeUIView(context: Context) -> SDAnimatedImageView { + let imageView = SDAnimatedImageView() + imageView.image = Self.image + imageView.contentMode = .scaleAspectFit + imageView.maxBufferSize = UInt.max + imageView.shouldIncrementalLoad = false + imageView.autoPlayAnimatedImage = true + imageView.startAnimating() + return imageView + } + + func updateUIView(_ imageView: SDAnimatedImageView, context: Context) { + guard imageView.image !== Self.image else { return } + imageView.image = Self.image + imageView.startAnimating() + } +} + +extension SplashLogoAnimatedImageView { + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: SDAnimatedImageView, + context: Context + ) -> CGSize? { + Self.size + } +} diff --git a/Projects/Presentation/Splash/Sources/View/SplashView.swift b/Projects/Presentation/Splash/Sources/View/SplashView.swift index 79398c0..1dbe247 100644 --- a/Projects/Presentation/Splash/Sources/View/SplashView.swift +++ b/Projects/Presentation/Splash/Sources/View/SplashView.swift @@ -29,13 +29,22 @@ public struct SplashView: View { Spacer() - Image(asset: .splashLogo) - .resizable() - .scaledToFit() - .frame(height: 203) + SplashLogoAnimation() + .equatable() Spacer() } } + .onAppear { + store.send(.view(.onAppear)) + } + } +} + +private struct SplashLogoAnimation: View, Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + SplashLogoAnimatedImageView() } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json new file mode 100644 index 0000000..d7809a1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "checkBlue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg new file mode 100644 index 0000000..111aec1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json new file mode 100644 index 0000000..0a671c8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "errorXmark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg new file mode 100644 index 0000000..2c72385 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json new file mode 100644 index 0000000..cba9a2a --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "PicK.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/PicK.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/PicK.svg new file mode 100644 index 0000000..b7b5c8c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/PicK.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json new file mode 100644 index 0000000..b627047 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "google.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg new file mode 100644 index 0000000..4acea62 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json new file mode 100644 index 0000000..0bae148 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "kakao.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg new file mode 100644 index 0000000..440bda1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json b/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json new file mode 100644 index 0000000..fa1e048 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json @@ -0,0 +1,1675 @@ +{ + "Colors": { + "brand": { + "primary": { + "50": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9529411792755127, + 0.9215686321258545, + 0.9137254953384399 + ], + "alpha": 1, + "hex": "#F3EBE9" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7232", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "100": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9058823585510254, + 0.843137264251709, + 0.8274509906768799 + ], + "alpha": 1, + "hex": "#E7D7D3" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7228", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "200": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8156862854957581, + 0.686274528503418, + 0.658823549747467 + ], + "alpha": 1, + "hex": "#D0AFA8" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7229", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "300": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.7215686440467834, + 0.5333333611488342, + 0.48627451062202454 + ], + "alpha": 1, + "hex": "#B8887C" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7236", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "400": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.6313725709915161, + 0.3764705955982208, + 0.3176470696926117 + ], + "alpha": 1, + "hex": "#A16051" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7230", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "500": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.5372549295425415, + 0.21960784494876862, + 0.14509804546833038 + ], + "alpha": 1, + "hex": "#893825" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7234", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "600": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.47843137383461, + 0.21176470816135406, + 0.14901961386203766 + ], + "alpha": 1, + "hex": "#7A3626" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7231", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "700": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.4431372582912445, + 0.21568627655506134, + 0.16470588743686676 + ], + "alpha": 1, + "hex": "#71372A" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7233", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "800": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.3960784375667572, + 0.19607843458652496, + 0.14901961386203766 + ], + "alpha": 1, + "hex": "#653226" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7235", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "900": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.30588236451148987, + 0.16470588743686676, + 0.12941177189350128 + ], + "alpha": 1, + "hex": "#4E2A21" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7237", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "Alpha": { + "8": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.5372549295425415, + 0.21960784494876862, + 0.14509804546833038 + ], + "alpha": 0.07999999821186066, + "hex": "#893825" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6766:7483", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + }, + "secondary": { + "50": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9882352948188782, + 0.9725490212440491, + 0.9450980424880981 + ], + "alpha": 1, + "hex": "#FCF8F1" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7292", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "100": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9764705896377563, + 0.9450980424880981, + 0.8901960849761963 + ], + "alpha": 1, + "hex": "#F9F1E3" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7293", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "200": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9529411792755127, + 0.8901960849761963, + 0.7803921699523926 + ], + "alpha": 1, + "hex": "#F3E3C7" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7294", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "300": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.929411768913269, + 0.8352941274642944, + 0.6745098233222961 + ], + "alpha": 1, + "hex": "#EDD5AC" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7295", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "400": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9058823585510254, + 0.7803921699523926, + 0.5647059082984924 + ], + "alpha": 1, + "hex": "#E7C790" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7300", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "500": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8823529481887817, + 0.7254902124404907, + 0.45490196347236633 + ], + "alpha": 1, + "hex": "#E1B974" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7296", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "600": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8076171875, + 0.66231369972229, + 0.411665141582489 + ], + "alpha": 1, + "hex": "#CEA969" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7297", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "700": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.7186185121536255, + 0.5878713130950928, + 0.3623323142528534 + ], + "alpha": 1, + "hex": "#B7965C" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7298", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "800": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.6392157077789307, + 0.5254902243614197, + 0.32549020648002625 + ], + "alpha": 1, + "hex": "#A38653" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7299", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "900": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.572549045085907, + 0.47058823704719543, + 0.29019609093666077 + ], + "alpha": 1, + "hex": "#92784A" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7301", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "beige": { + "50": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9960784316062927, + 0.9960784316062927, + 0.9921568632125854 + ], + "alpha": 1, + "hex": "#FEFEFD" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7325", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "100": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9921568632125854, + 0.9882352948188782, + 0.9843137264251709 + ], + "alpha": 1, + "hex": "#FDFCFB" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7324", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "200": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9843137264251709, + 0.9764705896377563, + 0.9686274528503418 + ], + "alpha": 1, + "hex": "#FBF9F7" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7326", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "300": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9764705896377563, + 0.9686274528503418, + 0.9490196108818054 + ], + "alpha": 1, + "hex": "#F9F7F2" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7327", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "400": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9686274528503418, + 0.95686274766922, + 0.9333333373069763 + ], + "alpha": 1, + "hex": "#F7F4EE" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7328", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "500": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9607843160629272, + 0.9450980424880981, + 0.9176470637321472 + ], + "alpha": 1, + "hex": "#F5F1EA" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7333", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "600": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9372549057006836, + 0.9176470637321472, + 0.8784313797950745 + ], + "alpha": 1, + "hex": "#EFEAE0" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7329", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "700": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8549019694328308, + 0.8196078538894653, + 0.7490196228027344 + ], + "alpha": 1, + "hex": "#DAD1BF" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7332", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "800": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8078431487083435, + 0.7568627595901489, + 0.658823549747467 + ], + "alpha": 1, + "hex": "#CEC1A8" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7331", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "900": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.7176470756530762, + 0.658823549747467, + 0.545098066329956 + ], + "alpha": 1, + "hex": "#B7A88B" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7330", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "neutral": { + "50": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9215686321258545, + 0.9215686321258545, + 0.9215686321258545 + ], + "alpha": 1, + "hex": "#EBEBEB" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7356", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "100": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.843137264251709, + 0.843137264251709, + 0.843137264251709 + ], + "alpha": 1, + "hex": "#D7D7D7" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7363", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "200": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.6901960968971252, + 0.686274528503418, + 0.6823529601097107 + ], + "alpha": 1, + "hex": "#B0AFAE" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7357", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "300": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.5333333611488342, + 0.529411792755127, + 0.5254902243614197 + ], + "alpha": 1, + "hex": "#888786" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7358", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "400": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.3803921639919281, + 0.37254902720451355, + 0.364705890417099 + ], + "alpha": 1, + "hex": "#615F5D" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7359", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "500": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.2235294133424759, + 0.21568627655506134, + 0.2078431397676468 + ], + "alpha": 1, + "hex": "#393735" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7360", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "600": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.16862745583057404, + 0.16470588743686676, + 0.1568627506494522 + ], + "alpha": 1, + "hex": "#2B2A28" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7362", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "700": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.13333334028720856, + 0.12941177189350128, + 0.125490203499794 + ], + "alpha": 1, + "hex": "#222120" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7364", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "800": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.10196078568696976, + 0.09803921729326248, + 0.0941176488995552 + ], + "alpha": 1, + "hex": "#1A1918" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7365", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "900": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.07450980693101883, + 0.07058823853731155, + 0.07058823853731155 + ], + "alpha": 1, + "hex": "#131212" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7361", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + }, + "semantic": { + "text": { + "primary": { + "$type": "color", + "$value": "{Colors.brand.neutral.900}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7367", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "secondary": { + "$type": "color", + "$value": "{Colors.brand.neutral.700}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7369", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "tertiary": { + "$type": "color", + "$value": "{Colors.brand.neutral.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7370", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "muted": { + "$type": "color", + "$value": "{Colors.brand.neutral.300}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7371", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "inverse": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9960784316062927, + 0.9960784316062927, + 0.9921568632125854 + ], + "alpha": 1, + "hex": "#FEFEFD" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7372", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.aliasData": { + "targetVariableId": "VariableID:5ca37bec2680585542952367424f7ecf45490cc3/-1:-1", + "targetVariableName": "beige color50", + "targetVariableSetId": "VariableCollectionId:bbcfac25774af1795702717ddc0dd55de8fd3823/-1:-1", + "targetVariableSetName": "beige color" + } + } + }, + "brand": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7373", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "border": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.600}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7399", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "subtle": { + "$type": "color", + "$value": "{Colors.brand.beige.700}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7400", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "disabled": { + "$type": "color", + "$value": "{Colors.brand.beige.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7401", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "selected": { + "$type": "color", + "$value": "{Colors.brand.secondary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7434", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "strong": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7467", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "focus": { + "$type": "color", + "$value": "{Colors.brand.beige.700}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7468", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "error": { + "$type": "color", + "$value": "{Colors.semantic.status.error.Alpha}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7469", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "surface": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.50}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7387", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "subtle": { + "$type": "color", + "$value": "{Colors.brand.beige.300}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7388", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "tertiary": { + "$type": "color", + "$value": "{Colors.brand.beige.400}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7389", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "selected": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7390", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "disabled": { + "$type": "color", + "$value": "{Colors.brand.primary.200}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7391", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "background": { + "default": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9803921580314636, + 0.9803921580314636, + 0.9764705896377563 + ], + "alpha": 1, + "hex": "#FAFAF9" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7382", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "subtle": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9607843160629272, + 0.9607843160629272, + 0.95686274766922 + ], + "alpha": 1, + "hex": "#F5F5F4" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7383", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "tertiary": { + "$type": "color", + "$value": "{Colors.brand.neutral.50}", + "$extensions": { + "com.figma.variableId": "VariableID:6715:7398", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "brand": { + "$type": "color", + "$value": "{Colors.brand.beige.200}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7384", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "inverse": { + "$type": "color", + "$value": "{Colors.brand.neutral.800}", + "$extensions": { + "com.figma.variableId": "VariableID:6708:7386", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "overlay": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0, + 0, + 0 + ], + "alpha": 0.4000000059604645, + "hex": "#000000" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6715:7392", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "status": { + "error": { + "error": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.7882353067398071, + 0.1764705926179886, + 0.20000000298023224 + ], + "alpha": 1, + "hex": "#C92D33" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7376", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "Alpha": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.7882353067398071, + 0.1764705926179886, + 0.20000000298023224 + ], + "alpha": 0.4000000059604645, + "hex": "#C92D33" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6761:657", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "warning": { + "warning": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 1, + 0.7058823704719543, + 0 + ], + "alpha": 1, + "hex": "#FFB400" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6708:7380", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "Alpha": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 1, + 0.7058823704719543, + 0 + ], + "alpha": 0.4000000059604645, + "hex": "#FFB400" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6761:658", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + } + } + }, + "Radius": { + "none": { + "$type": "number", + "$value": 0, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7000", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "default": { + "$type": "number", + "$value": 2, + "$extensions": { + "com.figma.variableId": "VariableID:6761:6998", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "full": { + "$type": "number", + "$value": 999, + "$extensions": { + "com.figma.variableId": "VariableID:6761:6999", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "Spacing": { + "0": { + "$type": "number", + "$value": 0, + "$extensions": { + "com.figma.variableId": "VariableID:6762:6955", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "2": { + "$type": "number", + "$value": 2, + "$extensions": { + "com.figma.variableId": "VariableID:6762:6956", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "4": { + "$type": "number", + "$value": 4, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7107", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "8": { + "$type": "number", + "$value": 8, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7111", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "16": { + "$type": "number", + "$value": 16, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7110", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "24": { + "$type": "number", + "$value": 24, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7108", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "32": { + "$type": "number", + "$value": 32, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7112", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "40": { + "$type": "number", + "$value": 40, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7104", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "48": { + "$type": "number", + "$value": 48, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7103", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "64": { + "$type": "number", + "$value": 64, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7109", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "80": { + "$type": "number", + "$value": 80, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7106", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "96": { + "$type": "number", + "$value": 96, + "$extensions": { + "com.figma.variableId": "VariableID:6761:7105", + "com.figma.hiddenFromPublishing": true, + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "Component": { + "bedge": { + "filled": { + "background": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.600}", + "$extensions": { + "com.figma.variableId": "VariableID:6792:1209", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "inverse": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6904:2076", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "text": { + "default": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6904:2078", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "inverse": { + "$type": "color", + "$value": "{Colors.brand.beige.50}", + "$extensions": { + "com.figma.variableId": "VariableID:6904:2082", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + }, + "outline": { + "text": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6872:7386", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "backround": { + "$type": "color", + "$value": "{Colors.brand.beige.50}", + "$extensions": { + "com.figma.variableId": "VariableID:6904:2067", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "border": { + "$type": "color", + "$value": "{Colors.brand.primary.100}", + "$extensions": { + "com.figma.variableId": "VariableID:6904:2071", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + }, + "input": { + "border": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.600}", + "$extensions": { + "com.figma.variableId": "VariableID:6792:1210", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "active": { + "$type": "color", + "$value": "{Colors.brand.beige.700}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7360", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "error": { + "$type": "color", + "$value": "{Colors.semantic.border.error}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7361", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "surface": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.50}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7362", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "disabled": { + "$type": "color", + "$value": "{Colors.brand.beige.300}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7363", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "text": { + "default": { + "$type": "color", + "$value": "{Colors.brand.neutral.300}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7367", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "active": { + "$type": "color", + "$value": "{Colors.brand.neutral.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7380", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "error": { + "$type": "color", + "$value": "{Colors.semantic.status.error.error}", + "$extensions": { + "com.figma.variableId": "VariableID:6857:7371", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + }, + "button": { + "radius": { + "$type": "number", + "$value": "{Radius.default}", + "$extensions": { + "com.figma.variableId": "VariableID:6854:7375", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "primary": { + "background": { + "default": { + "$type": "color", + "$value": "{Colors.brand.primary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6792:1208", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "pressed": { + "$type": "color", + "$value": "{Colors.brand.primary.800}", + "$extensions": { + "com.figma.variableId": "VariableID:6829:7354", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "disabled": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8156862854957581, + 0.686274528503418, + 0.658823549747467 + ], + "alpha": 1, + "hex": "#D0AFA8" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6829:7355", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.aliasData": { + "targetVariableId": "VariableID:cfdbc8ce010b4bf46c1aa1aebabc7686775c41ce/-1:-1", + "targetVariableName": "Primary200", + "targetVariableSetId": "VariableCollectionId:054680f45b5bcc4599f8f5dbddcbb91b1f17bca3/-1:-1", + "targetVariableSetName": "primary color" + } + } + } + }, + "text": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.50}", + "$extensions": { + "com.figma.variableId": "VariableID:6829:7367", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + } + }, + "secondary": { + "background": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.300}", + "$extensions": { + "com.figma.variableId": "VariableID:6841:7370", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "pressed": { + "$type": "color", + "$value": "{Colors.brand.beige.400}", + "$extensions": { + "com.figma.variableId": "VariableID:6904:2583", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "border": { + "default": { + "$type": "color", + "$value": "{Colors.brand.beige.600}", + "$extensions": { + "com.figma.variableId": "VariableID:6841:7373", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "pressed": { + "$type": "color", + "$value": "{Colors.brand.secondary.500}", + "$extensions": { + "com.figma.variableId": "VariableID:6841:7374", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + } + }, + "text": { + "default": { + "$type": "color", + "$value": "{Colors.brand.neutral.600}", + "$extensions": { + "com.figma.variableId": "VariableID:6854:7377", + "com.figma.scopes": [ + "ALL_SCOPES" + ] + } + }, + "disabled": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [ + 0.5333333611488342, + 0.529411792755127, + 0.5254902243614197 + ], + "alpha": 1, + "hex": "#888786" + }, + "$extensions": { + "com.figma.variableId": "VariableID:6904:2584", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.aliasData": { + "targetVariableId": "VariableID:e9a3ae05dbe56ce69dee81ab24ef760e560eede8/-1:-1", + "targetVariableName": "gray300", + "targetVariableSetId": "VariableCollectionId:58e4e447eaf6a6da4ebd7c821fb97c415c00f391/-1:-1", + "targetVariableSetName": "gray color" + } + } + } + } + } + } + }, + "$extensions": { + "com.figma.modeName": "Mode 1" + } +} \ No newline at end of file diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index cb0d4b7..499baa8 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -85,7 +85,9 @@ public extension ShapeStyle where Self == Color { static var bgOverlay: Color { .init(hex: "000000", alpha: 0.4) } static var bgSubtle: Color { .init(hex: "F5F5F4") } static var bgTertiary: Color { .neutral50 } - + static var borderGray: Color { .init(hex: "CCCCCC") } + static var gray50: Color { .init(hex: "FFFFFF")} + // MARK: - Semantic / Status static var statusErrorAlpha: Color { .init(hex: "C92D33", alpha: 0.4) } static var statusError: Color { .init(hex: "C92D33") } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 2decf73..c549a9a 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -10,9 +10,13 @@ import Foundation public enum ImageAsset: String { // MARK: - 소셜로그인 버튼 - case google - - case splashLogo + case loginLogo + case google + case kakao + + case errorXmark + case checkBlue + case none } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift new file mode 100644 index 0000000..2a54019 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift @@ -0,0 +1,84 @@ +// +// CTAButtonStyle.swift +// DesignSystem +// +// Picke CTA 버튼 디자인 토큰 (variant × size). +// + +import SwiftUI + +// MARK: - Variant + +public enum CTAButtonVariant: Sendable { + /// `ComponentToken.Button.Primary` 시리즈를 사용. + case primary +} + +public extension CTAButtonVariant { + func backgroundColor(isEnabled: Bool, isPressed: Bool = false) -> Color { + switch self { + case .primary: + guard isEnabled else { return ComponentToken.Button.Primary.Background.disabled } + return isPressed + ? ComponentToken.Button.Primary.Background.pressed + : ComponentToken.Button.Primary.Background.default + } + } + + func foregroundColor(isEnabled _: Bool) -> Color { + switch self { + case .primary: ComponentToken.Button.Primary.Text.default + } + } +} + +// MARK: - Size + +public enum CTAButtonSize: Sendable { + /// 풀 너비 CTA. 메인 액션용. + case large + /// 고정 너비 CTA. 보조 영역용. + case medium + /// 칩 형태 인라인 액션. + case small +} + +public extension CTAButtonSize { + var height: CGFloat { + switch self { + case .large: 56 + case .medium: 48 + case .small: 36 + } + } + + var horizontalPadding: CGFloat { + switch self { + case .large: .s24 + case .medium: .s16 + case .small: .s16 + } + } + + var iconSpacing: CGFloat { + switch self { + case .large, .medium: .s8 + case .small: .s4 + } + } + + var font: CustomSizeFont { + switch self { + case .large, .medium: .headingMedium + case .small: .labelSmall + } + } + + /// 부모 컨테이너의 가로 폭을 모두 채우는지. + var fillsWidth: Bool { + switch self { + case .large: true + case .medium, .small: false + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyleModifier.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyleModifier.swift new file mode 100644 index 0000000..34f53c7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyleModifier.swift @@ -0,0 +1,84 @@ +// +// CTAButtonStyleModifier.swift +// DesignSystem +// +// `Button` 위에 얹는 ButtonStyle + `View.ctaButtonStyle(...)` 단축 모디파이어. +// + +import SwiftUI + +// MARK: - ButtonStyle + +public struct CTAButtonStyle: ButtonStyle { + private let variant: CTAButtonVariant + private let size: CTAButtonSize + private let trailingIcon: Image? + + public init( + variant: CTAButtonVariant = .primary, + size: CTAButtonSize = .large, + trailingIcon: Image? = nil + ) { + self.variant = variant + self.size = size + self.trailingIcon = trailingIcon + } + + public func makeBody(configuration: Configuration) -> some View { + StyledLabel( + configuration: configuration, + variant: variant, + size: size, + trailingIcon: trailingIcon + ) + } + + private struct StyledLabel: View { + let configuration: Configuration + let variant: CTAButtonVariant + let size: CTAButtonSize + let trailingIcon: Image? + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + HStack(spacing: size.iconSpacing) { + configuration.label + .pretendardCustomFont(textStyle: size.font) + if let trailingIcon { + trailingIcon + } + } + .foregroundStyle(variant.foregroundColor(isEnabled: isEnabled)) + .padding(.horizontal, size.horizontalPadding) + .frame( + maxWidth: size.fillsWidth ? .infinity : nil, + minHeight: size.height + ) + .background( + variant.backgroundColor(isEnabled: isEnabled, isPressed: configuration.isPressed), + in: Capsule() + ) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } + } +} + +// MARK: - View shortcut + +public extension View { + /// `Button { ... } label: { ... }` 위에 적용하는 Picke CTA 스타일. + /// - Parameters: + /// - variant: 색 계열 (기본 `.primary`) + /// - size: `.large` / `.medium` / `.small` + /// - icon: 트레일링 아이콘 (옵션) + func ctaButtonStyle( + _ variant: CTAButtonVariant = .primary, + size: CTAButtonSize = .large, + icon: Image? = nil + ) -> some View { + buttonStyle( + CTAButtonStyle(variant: variant, size: size, trailingIcon: icon) + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButton.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButton.swift new file mode 100644 index 0000000..0e5f649 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButton.swift @@ -0,0 +1,54 @@ +// +// CustomButton.swift +// DesignSystem +// +// Created by Wonji Suh on 11/2/24. +// + +import SwiftUI + +public struct CustomButton: View { + private let action: () -> Void + private let title: String + private let config: PickeCustomButtonConfig + private let trailingIcon: Image? + private let textStyle: CustomSizeFont + private var isEnable: Bool + + public init( + action: @escaping () -> Void, + title: String, + config: PickeCustomButtonConfig, + isEnable: Bool = false, + trailingIcon: Image? = nil, + textStyle: CustomSizeFont = .headingMedium + ) { + self.title = title + self.config = config + self.action = action + self.isEnable = isEnable + self.trailingIcon = trailingIcon + self.textStyle = textStyle + } + + public var body: some View { + Button(action: action) { + HStack(spacing: .s8) { + Text(title) + .pretendardCustomFont(textStyle: textStyle) + if let trailingIcon { + trailingIcon + } + } + .foregroundStyle(isEnable ? config.enableFontColor : config.disableFontColor) + .frame(maxWidth: .infinity) + .frame(height: config.frameHeight) + .background( + isEnable ? config.enableBackgroundColor : config.disableBackgroundColor, + in: Capsule() + ) + } + .buttonStyle(.plain) + .disabled(!isEnable) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift new file mode 100644 index 0000000..bb75ac1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift @@ -0,0 +1,28 @@ +// +// CustomButtonConfig.swift +// DesignSystem +// +// Created by Wonji Suh on 11/2/24. +// + +import SwiftUI + +public class CustomButtonConfig: PickeCustomButtonConfig { + /// 기본 large primary CTA. 기존 호출처 호환을 위해 유지. + public static func create() -> PickeCustomButtonConfig { + primary(.large) + } + + /// CTA primary 팩토리. variant + size 조합을 `PickeCustomButtonConfig`로 변환한다. + public static func primary(_ size: CTAButtonSize) -> PickeCustomButtonConfig { + let variant: CTAButtonVariant = .primary + return PickeCustomButtonConfig( + cornerRadius: .full, + enableFontColor: variant.foregroundColor(isEnabled: true), + enableBackgroundColor: variant.backgroundColor(isEnabled: true), + frameHeight: size.height, + disableFontColor: variant.foregroundColor(isEnabled: false), + disableBackgroundColor: variant.backgroundColor(isEnabled: false) + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/PickeCustomButtonConfig.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/PickeCustomButtonConfig.swift new file mode 100644 index 0000000..047aac2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/PickeCustomButtonConfig.swift @@ -0,0 +1,33 @@ +// +// PickeCustomButtonConfig.swift +// DesignSystem +// +// Created by Wonji Suh on 11/2/24. +// + +import SwiftUI + +public class PickeCustomButtonConfig { + public let cornerRadius: CGFloat + public let enableFontColor: Color + public let enableBackgroundColor:Color + public let frameHeight: CGFloat + public let disableFontColor: Color + public let disableBackgroundColor:Color + + public init( + cornerRadius: CGFloat, + enableFontColor: Color, + enableBackgroundColor: Color, + frameHeight: CGFloat, + disableFontColor: Color, + disableBackgroundColor: Color + ) { + self.cornerRadius = cornerRadius + self.enableFontColor = enableFontColor + self.enableBackgroundColor = enableBackgroundColor + self.frameHeight = frameHeight + self.disableFontColor = disableFontColor + self.disableBackgroundColor = disableBackgroundColor + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/UI/Navigaion/UINavigationController+gesture.swift b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/UINavigationController+gesture.swift similarity index 100% rename from Projects/Shared/DesignSystem/Sources/Extension/UI/Navigaion/UINavigationController+gesture.swift rename to Projects/Shared/DesignSystem/Sources/UI/Navigaion/UINavigationController+gesture.swift diff --git a/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastManager.swift b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastManager.swift new file mode 100644 index 0000000..3dd68a4 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastManager.swift @@ -0,0 +1,97 @@ +// +// ToastManager.swift +// DesignSystem +// +// Created by Wonji Suh on 12/29/25. +// + +import SwiftUI +import Combine + +@MainActor +public class ToastManager: ObservableObject { + public static let shared = ToastManager() + + @Published public var currentToast: ToastType? + @Published public var isVisible = false + + private var dismissTask: Task? + private var hideAnimationTask: Task? + + // 🎯 PFW + 메모리 최적화: WeakSingleton 패턴 고려 + // TODO: SmartSingleton 프로토콜로 마이그레이션 예정 + // public class ToastManager: ObservableObject, SmartSingleton { + // required public init() {} + + private init() {} + + public func showToast( + _ toast: ToastType, + duration: TimeInterval? = 3.0 + ) { + // 기존 작업 취소 + dismissTask?.cancel() + hideAnimationTask?.cancel() + + // 새 토스트 표시 + currentToast = toast + withAnimation(.easeOut(duration: 0.3)) { + isVisible = true + } + + // 자동 dismiss 설정 + guard let duration else { + dismissTask = nil + return + } + + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard !Task.isCancelled else { return } + self?.hideToast() + } + } + + public func hideToast() { + dismissTask?.cancel() + dismissTask = nil + + withAnimation(.easeIn(duration: 0.3)) { + isVisible = false + } + + // 애니메이션 완료 후 토스트 제거 + hideAnimationTask?.cancel() + hideAnimationTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(0.3)) + guard !Task.isCancelled else { return } + self?.currentToast = nil + } + } + + // MARK: - 편의 메소드 + public func showSuccess(_ message: String) { + showToast(.success(message)) + } + + public func showError(_ message: String) { + showToast(.error(message)) + } + + public func showWarning(_ message: String) { + showToast(.warning(message)) + } + + public func showInfo(_ message: String) { + showToast(.info(message)) + } + + public func showLoading(_ message: String) { + showToast(.loading(message), duration: nil) + } + + deinit { + dismissTask?.cancel() + hideAnimationTask?.cancel() + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift new file mode 100644 index 0000000..490c8b3 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift @@ -0,0 +1,72 @@ +// +// ToastType.swift +// DesignSystem +// +// Created by Wonji Suh on 12/29/25. +// + +import SwiftUI + +public enum ToastType: Equatable { + case success(String) + case error(String) + case warning(String) + case info(String) + case loading(String) + + public var message: String { + switch self { + case .success(let message), + .error(let message), + .warning(let message), + .info(let message), + .loading(let message): + return message + } + } + + public var backgroundColor: Color { + switch self { + case .success: + return .neutral50 + case .error: + return .neutral50 + case .warning: + return .neutral50 + case .info: + return .neutral50 + case .loading: + return .neutral50 + } + } + + public var iconName: String? { + switch self { + case .success: + return "checkBlue" + case .error: + return "errorXmark" + case .warning: + return "errorXmark" + case .info: + return "info.circle.fill" + case .loading: + return nil + } + } + + public var iconColor: Color { + switch self { + case .success: + return .white + case .error: + return .red + case .warning: + return .red + case .info: + return .white + case .loading: + return .white + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastView.swift b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastView.swift new file mode 100644 index 0000000..3b046fd --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastView.swift @@ -0,0 +1,169 @@ +// +// ToastView.swift +// DesignSystem +// +// Created by Wonji Suh on 12/29/25. +// + +import SwiftUI + +public struct ToastView: View { + let toast: ToastType + + public init(toast: ToastType) { + self.toast = toast + } + + public var body: some View { + HStack(alignment: .center, spacing: 8) { + leadingView + + // 메시지 + Text(toast.message) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundColor(.black) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 20) + .padding(.vertical, 11) + .background(toast.backgroundColor) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } +} + +// MARK: - Toast Overlay Modifier +public struct ToastOverlay: ViewModifier { + @ObservedObject private var toastManager = ToastManager.shared + let position: ToastPosition + let horizontalPadding: CGFloat + let topPadding: CGFloat + let bottomPadding: CGFloat + + public init( + position: ToastPosition = .top, + horizontalPadding: CGFloat = 20, + topPadding: CGFloat = 30, + bottomPadding: CGFloat = 30 + ) { + self.position = position + self.horizontalPadding = horizontalPadding + self.topPadding = topPadding + self.bottomPadding = bottomPadding + } + + public func body(content: Content) -> some View { + content + .overlay(alignment: position.alignment) { + if let toast = toastManager.currentToast { + ToastView(toast: toast) + .padding(.horizontal, horizontalPadding) + .padding(.top, position == .top ? topPadding : 0) + .padding(.bottom, position == .bottom ? bottomPadding : 0) + .opacity(toastManager.isVisible ? 1 : 0) + .offset(y: toastManager.isVisible ? 0 : position.hiddenOffsetY) + .transition(.asymmetric( + insertion: .move(edge: position.edge).combined(with: .opacity), + removal: .move(edge: position.edge).combined(with: .opacity) + )) + .allowsHitTesting(toastManager.isVisible) + } + } + } +} + +public enum ToastPosition: Equatable { + case top + case bottom + + var alignment: Alignment { + switch self { + case .top: + return .top + case .bottom: + return .bottom + } + } + + var edge: Edge { + switch self { + case .top: + return .top + case .bottom: + return .bottom + } + } + + var hiddenOffsetY: CGFloat { + switch self { + case .top: + return -100 + case .bottom: + return 100 + } + } +} + +// MARK: - View Extension +public extension View { + func toastOverlay( + position: ToastPosition = .top, + horizontalPadding: CGFloat = 20, + topPadding: CGFloat = 30, + bottomPadding: CGFloat = 30 + ) -> some View { + modifier( + ToastOverlay( + position: position, + horizontalPadding: horizontalPadding, + topPadding: topPadding, + bottomPadding: bottomPadding + ) + ) + } +} + +// MARK: - Private views +private extension ToastView { + @ViewBuilder + var leadingView: some View { + switch toast { + case .loading: + ProgressView() + .progressViewStyle(.circular) + .tint(toast.iconColor) + .frame(width: 16, height: 16) + default: + if let iconName = toast.iconName { + Image(assetName: iconName) + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + } + } + } +} + +// MARK: - Preview +#Preview { + VStack(spacing: 20) { + Button("성공 토스트") { + ToastManager.shared.showSuccess("로그인에 성공했습니다!") + } + + Button("에러 토스트") { + ToastManager.shared.showError("인증에 실패했어요. 다시 시도해주세요..") + } + + Button("경고 토스트") { + ToastManager.shared.showWarning("네트워크 연결을 확인해주세요.") + } + + Button("정보 토스트") { + ToastManager.shared.showInfo("새로운 업데이트가 있습니다.") + } + } + .padding() + .toastOverlay() +} diff --git a/Projects/Shared/ThirdParty/Project.swift b/Projects/Shared/ThirdParty/Project.swift index 76a28a8..87e64f7 100644 --- a/Projects/Shared/ThirdParty/Project.swift +++ b/Projects/Shared/ThirdParty/Project.swift @@ -12,6 +12,7 @@ let project = Project.makeAppModule( dependencies: [ .SPM.composableArchitecture, .SPM.tcaFlow, + .SPM.sdwebImage ], sources: ["Sources/**"] diff --git a/Tools/README.md b/Tools/README.md index 699ea7f..9e70ba8 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -5,15 +5,38 @@ Tokens Studio for Figma 가 export 한 `Mode 1.tokens.json` 을 Swift 토큰으로 변환합니다. ### 입력 -- `Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json` +- `SWYP-Find/design-tokens` 레포의 `Mode 1.tokens.json` (단일 소스) +- 워크플로우 실행 시 raw URL 로 받아 로컬 `Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json` 에 임시 저장 (커밋되지 않음 — `.gitignore` 처리) ### 출력 (덮어쓰기) -- `Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift` — 컬러 (brand / semantic / status / bg) +- `Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift` — 색 토큰 (brand / semantic / status / bg) - `Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Radius+.swift` — radius (`.none` / `.default` / `.full`) - `Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Spacing+.swift` — spacing (`.s0` ~ `.s96`) +- `Projects/Shared/DesignSystem/Sources/UI/Token/ComponentToken.swift` — 컴포넌트 토큰 (`ComponentToken.Button.Primary.Background.default` 등) -### 실행 +### 자동화 (권장 경로) +디자이너가 `SWYP-Find/design-tokens` 레포의 `Mode 1.tokens.json` 을 push 하면 다음이 자동으로 진행됩니다: + +``` +design-tokens main 브랜치에 푸시 + ↓ notify-ios.yml + ↓ repository_dispatch(design-tokens-updated) +Picke-iOS sync-design-tokens.yml + ├ raw URL 로 JSON 다운로드 + ├ swift Tools/TokenGenerator.swift + └ develop 브랜치에 4개 출력 파일 직접 commit + push +``` + +수동 트리거가 필요할 때: +``` +gh workflow run sync-design-tokens.yml --repo SWYP-Find/Picke-iOS ``` + +### 로컬 실행 (선택) +JSON 을 로컬에 두고 코드젠 결과를 미리 확인하고 싶을 때만: +``` +curl -fsSL https://raw.githubusercontent.com/SWYP-Find/design-tokens/main/Mode%201.tokens.json \ + -o "Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json" swift Tools/TokenGenerator.swift ``` @@ -26,12 +49,9 @@ Text("Hello") VStack(spacing: .s16) { RoundedRectangle(cornerRadius: .default) } -``` -### 디자이너 핸드오프 흐름 -1. 디자이너가 Tokens Studio → `Mode 1.tokens.json` export -2. 위 경로에 덮어쓰기 -3. `swift Tools/TokenGenerator.swift` 실행 -4. 빌드 검증 → 커밋 +Button("입장 선택하기") { } + .ctaButtonStyle(.primary, size: .large) // ComponentToken.Button.Primary.* 참조 +``` -⚠️ 출력 3개 파일은 자동 생성됩니다. 직접 수정하지 마세요. +⚠️ 출력 4개 파일은 자동 생성됩니다. **직접 수정 금지** — 변경하려면 디자이너의 Tokens Studio 토큰 자체를 수정해야 합니다. diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 993cf17..ddd1c9a 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -2,32 +2,33 @@ @preconcurrency import PackageDescription #if TUIST -@preconcurrency import ProjectDescription + @preconcurrency import ProjectDescription -let packageSettings = PackageSettings( - productTypes: [ - "ComposableArchitecture": .staticFramework, - "Dependencies": .staticFramework, - "TCAFlow": .staticFramework, - "Moya": .staticFramework, - "LogMacro": .staticFramework, - "AsyncMoya": .staticFramework, - "AppAuth": .staticFramework, - "AppAuthCore": .staticFramework, - "GTMAppAuth": .staticFramework, - "GTMSessionFetcherCore": .staticFramework, - "IssueReporting": .staticFramework, - "IssueReportingPackageSupport": .staticFramework, - "XCTestDynamicOverlay": .staticFramework, - "Clocks": .staticFramework, - "ConcurrencyExtras": .staticFramework, - "WeaveDI": .staticFramework, - "ReactiveSwift": .staticFramework, - "SDWebImageSwiftUI": .staticFramework, - "Mixpanel": .staticFramework, - "MixpanelSessionReplay": .staticFramework - ] -) + let packageSettings = PackageSettings( + productTypes: [ + "ComposableArchitecture": .staticFramework, + "Dependencies": .staticFramework, + "TCAFlow": .staticFramework, + "Moya": .staticFramework, + "LogMacro": .staticFramework, + "AsyncMoya": .staticFramework, + "AppAuth": .staticFramework, + "AppAuthCore": .staticFramework, + "GTMAppAuth": .staticFramework, + "GTMSessionFetcherCore": .staticFramework, + "IssueReporting": .staticFramework, + "IssueReportingPackageSupport": .staticFramework, + "XCTestDynamicOverlay": .staticFramework, + "Clocks": .staticFramework, + "ConcurrencyExtras": .staticFramework, + "WeaveDI": .staticFramework, + "ReactiveSwift": .staticFramework, + "SDWebImageSwiftUI": .staticFramework, + "Mixpanel": .staticFramework, + "MixpanelSessionReplay": .staticFramework, + "GoogleMobileAds": .staticFramework, + ] + ) #endif let package = Package( @@ -38,13 +39,14 @@ let package = Package( .package(url: "https://github.com/Roy-wonji/TCAFlow.git", exact: "1.1.2"), .package(url: "https://github.com/Roy-wonji/WeaveDI.git", from: "3.4.1"), .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.1.0"), - .package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"), + .package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"), .package(url: "https://github.com/openid/AppAuth-iOS.git", from: "2.0.0"), .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "6.7.0"), .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.2.0"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "12.12.0"), .package(url: "https://github.com/SDWebImage/SDWebImageSwiftUI.git", from: "3.1.4"), .package(url: "https://github.com/mixpanel/mixpanel-swift.git", from: "5.1.3"), - .package(url: "https://github.com/mixpanel/mixpanel-ios-session-replay-package", exact: "1.4.0") + .package(url: "https://github.com/mixpanel/mixpanel-ios-session-replay-package", exact: "1.4.0"), + .package(url: "https://github.com/googleads/swift-package-manager-google-mobile-ads", from: "12.0.0"), ] )