diff --git a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift index 0c4e1722..f31bf531 100644 --- a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift +++ b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift @@ -13,13 +13,14 @@ public extension TargetDependency.SPM { static let composableArchitecture = TargetDependency.external(name: "ComposableArchitecture", condition: .none) static let dependencies = TargetDependency.external(name: "Dependencies", condition: .none) + static let sharing = TargetDependency.external(name: "Sharing", condition: .none) + static let sqliteData = TargetDependency.external(name: "SQLiteData", condition: .none) static let identifiedCollections = TargetDependency.external(name: "IdentifiedCollections", condition: .none) static let tcaFlow = TargetDependency.external(name: "TCAFlow", condition: .none) static let concurrencyExtras = TargetDependency.external(name: "ConcurrencyExtras", condition: .none) static let sdwebImageCore = TargetDependency.external(name: "SDWebImage", condition: .none) static let sdwebImage = TargetDependency.external(name: "SDWebImageSwiftUI", condition: .none) static let kingfisher = TargetDependency.external(name: "Kingfisher", condition: .none) - static let weaveDI = TargetDependency.external(name: "WeaveDI", condition: .none) static let googleSignIn = TargetDependency.external(name: "GoogleSignIn", condition: .none) static let appAuth: TargetDependency = .external(name: "AppAuth") diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/FeatureModule.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/FeatureModule.swift deleted file mode 100644 index 2b763037..00000000 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/FeatureModule.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// FeatureModule.swift -// Plugins -// -// Domain·Data 레이어를 Presentation 처럼 feature별 마이크로 모듈로 재편하기 위한 -// 접근자. 기존 레이어 단일 모듈 접근자(`.Domain(implements:)` / `.Data(implements:)`)와 -// 시그니처가 달라 마이그레이션 기간 동안 공존한다. -// -// - Domain feature: `Projects/Domain/` 의 마이크로피처 4타깃 -// (`DomainInterface` / `Domain` / `DomainTesting` / Tests) -// - Data feature: `Projects/Data/` 의 단일 타깃 `Data` -// - -import Foundation -import ProjectDescription - -// MARK: - Feature 모듈 식별자 - -public enum DomainFeatureModule: String, CaseIterable { - case Attendance - case Auth - case Battle - case Comment - case Home - case Notification - case Perspective - case Profile - case Search - /// 여러 feature 가 공유하는 저변경 횡단 계약(AppUpdate/Analytics/Device 등) - case Common -} - -public enum DataFeatureModule: String, CaseIterable { - case Attendance - case Auth - case Battle - case Comment - case Home - case Notification - case Perspective - case Profile - case Search - case Common -} - -/// Domain feature 모듈 내 대상 타깃 종류. -public enum FeatureTargetKind { - case interface - case implementation - case testing -} - -// MARK: - Path - -public extension ProjectDescription.Path { - static func DomainFeature(_ module: DomainFeatureModule) -> Self { - .relativeToRoot("Projects/Domain/\(module.rawValue)") - } - - static func DataFeature(_ module: DataFeatureModule) -> Self { - .relativeToRoot("Projects/Data/\(module.rawValue)") - } -} - -// MARK: - TargetDependency - -public extension TargetDependency { - /// feature별 Domain 모듈. 기본은 구현 타깃(`Domain`). - /// Data·Presentation 은 컴파일 격리를 위해 `.interface` 만 의존하는 것을 권장. - static func Domain(_ module: DomainFeatureModule, _ kind: FeatureTargetKind = .implementation) -> Self { - let suffix = switch kind { - case .interface: "DomainInterface" - case .implementation: "Domain" - case .testing: "DomainTesting" - } - return .project(target: "\(module.rawValue)\(suffix)", path: .DomainFeature(module)) - } - - /// feature별 Data 모듈(단일 타깃 `Data`). - static func Data(_ module: DataFeatureModule) -> Self { - .project(target: "\(module.rawValue)Data", path: .DataFeature(module)) - } -} diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 9e688e51..2460b94f 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -2,71 +2,88 @@ // Modules.swift // Plugins // -// Created by 서원지 on 2/21/24. +// 레이어별 모듈 카탈로그(단일 출처). +// 모듈 추가 = case 한 줄. rawValue 가 실제 타깃명이자 디렉토리명이라 오타로 깨지지 않는다. // import Foundation import ProjectDescription -public enum ModulePath { - case Network(Networks) - case Domain(Domains) - case Data(Datas) - case Shared(Shareds) -} +public enum FeatureModule: String, CaseIterable { + case auth = "Auth" + case home = "Home" + case chat = "Chat" + case hifi = "Hifi" + case web = "Web" + case battle = "Battle" + case profile = "Profile" + case notification = "Notification" + case ad = "Ad" + case featureSharedUI = "FeatureSharedUI" -// MARK: - CoreDomainModule + /// Projects/Feature/ + var path: Path { + return .relativeToFeature(rawValue) + } +} -public extension ModulePath { - enum Networks: String, CaseIterable { - case NetworkModule - case Networking - case NetworkToken - case NetworkHeader - case ThirdPartys +public enum CoreModule: String, CaseIterable { + case assembly = "CoreAssembly" + case logger = "PickeCoreLogger" + case network = "PickeNetwork" + case storage = "PickeStorage" + case coreUI = "PickeCoreUI" + case coreUtility = "PickeCoreUtility" + case thirdParty = "PickeThirdParty" - public static let name: String = "Network" + /// Projects/Core/ + var path: Path { + return .relativeToCore(rawValue) } } -// MARK: - CoreMoudule +public enum ServiceModule: String, CaseIterable { + case assembly = "ServiceAssembly" + case api = "API" + case apiEndpoint = "APIEndpoint" + case analytics = "PickeAnalytics" + case config = "PickeConfig" + case audioPlayer = "AudioPlayerService" + case device = "DeviceService" + case auth = "PickeAuth" -public extension ModulePath { - enum Datas: String, CaseIterable { - case Model - case Data - case Repository - case API - case Service - case DataTesting - - public static let name: String = "Data" + /// Projects/Service/ + var path: Path { + return .relativeToService(rawValue) } } -// MARK: - CoreMoudule - -public extension ModulePath { - enum Domains: String, CaseIterable { - case Entity - case UseCase - case Domain - case DomainInterface - case DomainTesting +public enum DomainModule: String, CaseIterable { + case assembly = "DomainAssembly" + case appUpdate = "AppUpdateDomain" + case attendance = "AttendanceDomain" + case auth = "AuthDomain" + case battle = "BattleDomain" + case comment = "CommentDomain" + case home = "HomeDomain" + case notification = "NotificationDomain" + case perspective = "PerspectiveDomain" + case profile = "ProfileDomain" + case search = "SearchDomain" - public static let name: String = "Domain" + /// Projects/Domain/ + var path: Path { + return .relativeToDomain(rawValue) } } -public extension ModulePath { - enum Shareds: String, CaseIterable { - case Shared - case PickeDesignKit - case Utill - /// 광고 SDK(AdFit) 전용 모듈. 디자인 시스템과 분리해, 광고를 노출하는 화면만 명시적으로 의존한다. - case AdKit +public enum UIModule: String, CaseIterable { + case animation = "PickeAnimation" + case designKit = "PickeDesignKit" + case sharedUI = "PickeSharedUI" - public static let name: String = "Shared" - case ThirdParty + /// Projects/UI/ + var path: Path { + return .relativeToUI(rawValue) } } diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Path+Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Path+Modules.swift index 5d4a3662..3ab912ec 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Path+Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Path+Modules.swift @@ -2,52 +2,30 @@ // Path+Modules.swift // Plugins // -// Created by 서원지 on 2/21/24. +// 레이어 루트 경로. 카탈로그가 rawValue 만 넘겨 재사용한다. // import Foundation import ProjectDescription -// MARK: ProjectDescription.Path + PickeDesignKit public extension ProjectDescription.Path { - static var Shared: Self { - return .relativeToRoot("Projects/\(ModulePath.Shareds.name)") + static func relativeToFeature(_ name: String) -> Self { + return .relativeToRoot("Projects/Feature/\(name)") } - - static func Shared(implementation module: ModulePath.Shareds) -> Self { - return .relativeToRoot("Projects/\(ModulePath.Shareds .name)/\(module.rawValue)") - } -} - -// MARK: - Network -public extension ProjectDescription.Path { - static var Networking: Self { - return .relativeToRoot("Projects/\(ModulePath.Networks.name)") - } - - static func Network(implementation module: ModulePath.Networks) -> Self { - return .relativeToRoot("Projects/\(ModulePath.Networks.name)/\(module.rawValue)") - } -} -// MARK: - Domain -public extension ProjectDescription.Path { - static var Domain: Self { - return .relativeToRoot("Projects/\(ModulePath.Domains.name)") + static func relativeToCore(_ name: String) -> Self { + return .relativeToRoot("Projects/Core/\(name)") } - static func Domain(implementation module: ModulePath.Domains) -> Self { - return .relativeToRoot("Projects/\(ModulePath.Domains.name)/\(module.rawValue)") + static func relativeToService(_ name: String) -> Self { + return .relativeToRoot("Projects/Service/\(name)") } -} -// MARK: - Data -public extension ProjectDescription.Path { - static var Data: Self { - return .relativeToRoot("Projects/\(ModulePath.Datas.name)") + static func relativeToDomain(_ name: String) -> Self { + return .relativeToRoot("Projects/Domain/\(name)") } - static func Data(implementation module: ModulePath.Datas) -> Self { - return .relativeToRoot("Projects/\(ModulePath.Datas.name)/\(module.rawValue)") + static func relativeToUI(_ name: String) -> Self { + return .relativeToRoot("Projects/UI/\(name)") } } diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/TargetDependency+Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/TargetDependency+Modules.swift index 1866ebc8..7b451dc3 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/TargetDependency+Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/TargetDependency+Modules.swift @@ -2,45 +2,85 @@ // TargetDependency+Modules.swift // Plugins // -// Created by 서원지 on 2/21/24. +// 레이어 의존성 DSL. 카탈로그가 경로를 들고 있어 여기서는 타깃만 가리킨다. +// 모듈이 Interface 타깃(`Project.makeModule(hasInterface: true)`)을 가지면 +// `.domain(.auth, .interface)` 처럼 어느 타깃에 의존할지 명시할 수 있다. +// 기본값은 레이어별로 다르다. Feature·Domain 은 모든 모듈이 Interface 를 갖춰 +// `.interface` 가 기본이고, 구현을 링크하는 조립 레이어만 `.implementation` 을 명시한다. +// Core·Service·UI 는 아직 Interface 가 없는 모듈이 남아 `.implementation` 이 기본이다. // import Foundation import ProjectDescription -// 공통 헬퍼 -private extension TargetDependency { - static func projectTarget(_ name: String, path: ProjectDescription.Path) -> Self { - .project(target: name, path: path) - } +// MARK: - ModuleTarget + +/// 모듈 의존 시 어느 타깃을 가리킬지. 레이어 무관 공통 개념. +public enum ModuleTarget { + case interface + case implementation + /// 마이크로피처 모듈이 만드는 "Testing" 타깃. + /// 테스트 더블·픽스처 전용이라 테스트 타깃(testDependencies)에서만 참조한다. + case testing } -// Shared -public extension TargetDependency { - static func Shared(implements module: ModulePath.Shareds) -> Self { - projectTarget(module.rawValue, path: .Shared(implementation: module)) +extension TargetDependency { + /// interface → "Interface", implementation → "", testing → "Testing" 타깃. + static func moduleDependency(name: String, path: Path, target: ModuleTarget) -> TargetDependency { + switch target { + case .interface: + return .project(target: "\(name)Interface", path: path) + case .implementation: + return .project(target: name, path: path) + case .testing: + return .project(target: "\(name)Testing", path: path) + } } } +// MARK: - Layer DSL -// Network public extension TargetDependency { - static func Network(implements module: ModulePath.Networks) -> Self { - projectTarget(module.rawValue, path: .Network(implementation: module)) + /// 피처 의존성. 피처끼리는 상대의 Interface 에만 의존하고, + /// 구현 연결은 조립 레이어(FeatureAssembly/App)에서만 `.implementation` 으로 명시한다. + static func feature(_ module: FeatureModule, _ target: ModuleTarget = .interface) -> Self { + return .moduleDependency(name: module.rawValue, path: module.path, target: target) } -} + /// 모든 피처를 묶고 구현을 등록하는 엄브렐러 모듈 (App 진입점). + static var featureAssembly: Self { + return .project(target: "FeatureAssembly", path: .relativeToFeature("FeatureAssembly")) + } -// Domain -public extension TargetDependency { - static func Domain(implements module: ModulePath.Domains) -> Self { - projectTarget(module.rawValue, path: .Domain(implementation: module)) + static func core(_ module: CoreModule, _ target: ModuleTarget = .implementation) -> Self { + return .moduleDependency(name: module.rawValue, path: module.path, target: target) } -} -// Data -public extension TargetDependency { - static func Data(implements module: ModulePath.Datas) -> Self { - projectTarget(module.rawValue, path: .Data(implementation: module)) + /// 최하위 기반 모듈 구현을 묶어 제공하는 엄브렐러 모듈. + static var coreAssembly: Self { + return .core(.assembly) + } + + static func service(_ module: ServiceModule, _ target: ModuleTarget = .implementation) -> Self { + return .moduleDependency(name: module.rawValue, path: module.path, target: target) + } + + /// SDK 래핑 서비스 구현을 묶어 제공하는 엄브렐러 모듈. + static var serviceAssembly: Self { + return .service(.assembly) + } + + static func domain(_ module: DomainModule, _ target: ModuleTarget = .interface) -> Self { + return .moduleDependency(name: module.rawValue, path: module.path, target: target) + } + + /// 도메인 구현을 런타임에 조립하는 App 진입 경계. + /// 조립 모듈 자체는 Interface 타깃이 없어 구현을 직접 가리킨다. + static var domainAssembly: Self { + return .domain(.assembly, .implementation) + } + + static func ui(_ module: UIModule, _ target: ModuleTarget = .implementation) -> Self { + return .moduleDependency(name: module.rawValue, path: module.path, target: target) } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index b827fa92..e01c4555 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -9,7 +9,7 @@ import Foundation import ProjectDescription public extension String { - static func appVersion(version: String = "1.0.7") -> String { + static func appVersion(version: String = "1.0.8") -> String { return version } @@ -17,7 +17,7 @@ public extension String { return Project.Environment.bundlePrefix } - static func appBuildVersion(buildVersion: String = "2607292356") -> String { + static func appBuildVersion(buildVersion: String = "2607300031") -> String { return buildVersion } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/FileHeaderTemplate+.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/FileHeaderTemplate+.swift new file mode 100644 index 00000000..b856c3f1 --- /dev/null +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/FileHeaderTemplate+.swift @@ -0,0 +1,25 @@ +// +// FileHeaderTemplate+.swift +// ProjectDescriptionHelpers +// +// Xcode 파일 헤더(IDETemplateMacros). 새 파일 생성 시 자동 삽입된다. +// ___FILENAME___/___PACKAGENAME___/___DATE___/___YEAR___ 는 Xcode 가 채운다. +// 작성자는 기여자와 무관하게 프로젝트명으로 고정한다. +// + +import ProjectDescription + +public extension FileHeaderTemplate { + /// 프로젝트 공통 파일 헤더 (저작권 포함). + static var `default`: FileHeaderTemplate { + """ + // + // ___FILENAME___ + // ___PACKAGENAME___ + // + // Created by Picke on ___DATE___. + // Copyright © ___YEAR___ Picke. All rights reserved. + // + """ + } +} diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ModuleType.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ModuleType.swift deleted file mode 100644 index e2a4806b..00000000 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ModuleType.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ModuleType.swift -// ProjectTemplatePlugin -// -// Project.configure 의 진입 분기 타입. -// - -import ProjectDescription - -public enum ModuleTarget { - case interface - case implementation - case testing -} - -public enum PresentationFeatureModule: String, CaseIterable { - case Presentation - case Splash - case Auth - case Home - case Chat - case Hifi - case Web - case Battle - case Profile - case Notification -} - -public enum ModuleType { - case app - case feature(PresentationFeatureModule) - case module(name: String) - /// Data/Domain 등 비-Presentation 모듈을 마이크로피처(Interface/구현/Testing/Tests) 4타깃으로 구성. - case microModule(name: String) -} diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+App.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+App.swift index f85eb52d..127b7f7b 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+App.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+App.swift @@ -48,7 +48,7 @@ extension Project { settings: ProjectDescription.Settings, scripts: [ProjectDescription.TargetScript] = [], dependencies: [ProjectDescription.TargetDependency] = [], - sources _: ProjectDescription.SourceFilesList = ["Sources/**"], + sources: ProjectDescription.SourceFilesList = ["Sources/**"], resources: ProjectDescription.ResourceFileElements? = nil, infoPlist: ProjectDescription.InfoPlist = .default, entitlements: ProjectDescription.Entitlements? = nil, @@ -62,7 +62,8 @@ extension Project { bundleId: bundleId, deploymentTargets: deploymentTarget, infoPlist: infoPlist, - buildableFolders: resources != nil ? ["Sources", "Resources"] : ["Sources"], + sources: sources, + buildableFolders: resources != nil ? ["Resources"] : [], entitlements: entitlements, scripts: scripts, dependencies: dependencies, @@ -88,13 +89,15 @@ extension Project { return Project( name: name, options: .options( + automaticSchemesOptions: .enabled(codeCoverageEnabled: true), defaultKnownRegions: ["en", "ko"], developmentRegion: "ko" ), packages: packages, settings: settings, targets: targets, - schemes: schemes + schemes: schemes, + fileHeaderTemplate: .default ) } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Enviorment.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Enviorment.swift index ca4961ca..cbac7d6a 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Enviorment.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Enviorment.swift @@ -11,15 +11,7 @@ import ProjectDescription public extension Project { enum Environment { // 환경변수에서 프로젝트 이름을 읽어오고, 없으면 기본값 사용 - private static let projectName: String = { - if let envProjectName = ProcessInfo.processInfo.environment["PROJECT_NAME"] { - print("🔍 [Project+Environment] PROJECT_NAME 환경변수 발견: \(envProjectName)") - return envProjectName - } else { - print("🎵 [Project+Environment] ProjectConfig에서 프로젝트 이름 사용: \(ProjectConfig.projectName)") - return ProjectConfig.projectName - } - }() + private static let projectName = ProcessInfo.processInfo.environment["PROJECT_NAME"] ?? ProjectConfig.projectName // 🎯 모든 설정을 ProjectConfig에서 가져오거나 환경변수 우선 적용 private static let bundleIdPrefix = ProcessInfo.processInfo.environment["BUNDLE_ID_PREFIX"] ?? ProjectConfig.bundleIdPrefix private static let teamId = ProcessInfo.processInfo.environment["TEAM_ID"] ?? ProjectConfig.teamId diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Feature.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Feature.swift deleted file mode 100644 index 91dd011f..00000000 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Feature.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// Project+Feature.swift -// ProjectTemplatePlugin -// -// feature 모듈 타입의 Project 구성 (MicroFeature). -// 타깃: Interface + 구현 + Testing + Tests. -// - -import ProjectDescription - -extension Project { - static func configureFeature( - name: String, - bundleId: String, - platform _: Platform = .iOS, - product: Product = .staticFramework, - deploymentTarget: ProjectDescription.DeploymentTargets = Environment.deploymentTarget, - destinations: ProjectDescription.Destinations = Environment.deploymentDestination, - settings: ProjectDescription.Settings, - interfaceDependencies: [ProjectDescription.TargetDependency] = [], - dependencies: [ProjectDescription.TargetDependency] = [], - testingDependencies: [ProjectDescription.TargetDependency] = [], - resources: ProjectDescription.ResourceFileElements? = nil, - schemes: [ProjectDescription.Scheme] = [] - ) -> Project { - let interfaceTargetName = "\(name)Interface" - let testingTargetName = "\(name)Testing" - - let interfaceTarget: Target = .target( - name: interfaceTargetName, - destinations: destinations, - product: product, - bundleId: "\(bundleId).Interface", - deploymentTargets: deploymentTarget, - infoPlist: .default, - buildableFolders: ["Interface"], - dependencies: interfaceDependencies, - settings: suppressWarningsSettings - ) - - let featureTarget: Target = .target( - name: name, - destinations: destinations, - product: product, - bundleId: bundleId, - deploymentTargets: deploymentTarget, - infoPlist: .default, - buildableFolders: resources != nil ? ["Sources", "Resources"] : ["Sources"], - dependencies: [.target(name: interfaceTargetName)] + dependencies, - settings: suppressWarningsSettings - ) - - let testingTarget: Target = .target( - name: testingTargetName, - destinations: destinations, - product: product, - bundleId: "\(bundleId).Testing", - deploymentTargets: deploymentTarget, - infoPlist: .default, - buildableFolders: ["Testing"], - dependencies: [ - .target(name: interfaceTargetName), - .target(name: name), - ] + testingDependencies, - settings: suppressWarningsSettings - ) - - let testTarget = makeTestsTarget( - name: name, - bundleId: bundleId, - destinations: destinations, - deploymentTarget: deploymentTarget, - dependencies: [ - .target(name: name), - .target(name: testingTargetName), - ] - ) - - return Project( - name: name, - settings: settings.injectingModuleConfigurationsIfNeeded(), - targets: [ - interfaceTarget, - featureTarget, - testingTarget, - testTarget, - ], - schemes: schemes - ) - } -} diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Module.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Module.swift deleted file mode 100644 index 3e007c08..00000000 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Module.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// Project+Module.swift -// ProjectTemplatePlugin -// -// 일반/엄브렐러 모듈 타입의 Project 구성. -// - -import ProjectDescription - -extension Project { - static func configureModule( - name: String = Environment.appName, - bundleId: String, - platform _: Platform = .iOS, - product: Product, - packages: [Package] = [], - deploymentTarget: ProjectDescription.DeploymentTargets = Environment.deploymentTarget, - destinations: ProjectDescription.Destinations = Environment.deploymentDestination, - settings: ProjectDescription.Settings, - scripts: [ProjectDescription.TargetScript] = [], - dependencies: [ProjectDescription.TargetDependency] = [], - sources _: ProjectDescription.SourceFilesList = ["Sources/**"], - resources: ProjectDescription.ResourceFileElements? = nil, - infoPlist: ProjectDescription.InfoPlist = .default, - entitlements: ProjectDescription.Entitlements? = nil, - schemes: [ProjectDescription.Scheme] = [], - hasTests: Bool = false, - demoDisplayName: String? = nil - ) -> Project { - let moduleTarget: Target = .target( - name: name, - destinations: destinations, - product: product, - bundleId: bundleId, - deploymentTargets: deploymentTarget, - infoPlist: infoPlist, - buildableFolders: resources != nil ? ["Sources", "Resources"] : ["Sources"], - entitlements: entitlements, - scripts: scripts, - dependencies: dependencies, - settings: suppressWarningsSettings - ) - - var targets: [Target] = [moduleTarget] - - if hasTests { - targets.append( - makeTestsTarget( - name: name, - bundleId: bundleId, - destinations: destinations, - deploymentTarget: deploymentTarget, - dependencies: [.target(name: name)] - ) - ) - } - - var allSchemes = schemes - if let demoDisplayName { - let demoName = "\(name)Demo" - targets.append( - .target( - name: demoName, - destinations: destinations, - product: .app, - bundleId: "\(bundleId).Demo", - deploymentTargets: deploymentTarget, - infoPlist: .extendingDefault(with: [ - "UILaunchScreen": [:], - "CFBundleDisplayName": .string(demoDisplayName), - ]), - buildableFolders: ["Demo"], - dependencies: [.target(name: name)], - settings: suppressWarningsSettings - ) - ) - allSchemes.append( - .scheme( - name: demoName, - shared: true, - buildAction: .buildAction(targets: [.target(demoName)]), - runAction: .runAction(configuration: .stage, executable: "\(demoName)") - ) - ) - } - - return Project( - name: name, - packages: packages, - settings: settings.injectingModuleConfigurationsIfNeeded(), - targets: targets, - schemes: allSchemes - ) - } -} diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Target.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Target.swift index 46bf17a6..52911209 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Target.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Target.swift @@ -9,13 +9,19 @@ import ProjectDescription let suppressWarningsSettings: ProjectDescription.Settings = .settings( base: [ - "OTHER_SWIFT_FLAGS": "$(inherited) -suppress-warnings", + // Xcode 26 의 XCTest 가 먼저 로드하는 애플 private `Sharing` 모듈과 충돌해 xctest 부팅이 깨진다. + // Point-Free 구현은 `PickePointFreeSharing` 이름으로 빌드하고(Tuist/Package.swift), + // 소스의 `import Sharing` 은 별칭으로 이어 붙인다. + "OTHER_SWIFT_FLAGS": "$(inherited) -suppress-warnings -module-alias Sharing=PickePointFreeSharing", // Xcode 16 Explicitly Built Modules 비활성화. // (system 모듈(os_object)/WebKit pcm emit 실패 및 "implicit use of module files is disabled" 에러 회피) "SWIFT_ENABLE_EXPLICIT_MODULES": "NO", "_EXPERIMENTAL_SWIFT_EXPLICIT_MODULES": "NO", "CLANG_ENABLE_EXPLICIT_MODULES": "NO", - ] + ], + // recommended 기본값은 타깃 레벨에 CODE_SIGN_IDENTITY = "iPhone Developer" 를 심어 + // 프로젝트 설정의 값을 덮는다. match 가 발급하는 건 Apple Development 이므로 이 키만 제외한다. + defaultSettings: .recommended(excluding: ["CODE_SIGN_IDENTITY"]) ) extension Project { diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift index 8d6250cd..a198af8a 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift @@ -2,18 +2,18 @@ // Project+Template.swift // ProjectTemplatePlugin // -// 모듈 타입에 따라 Project 를 구성하는 단일 진입점. -// 각 Project.swift 는 이 함수만 호출한다. +// 모듈 하나를 구성하는 단일 진입점. +// Interface·Testing·Demo·Tests 는 플래그로 켠다 — buildableFolders 는 폴더가 없으면 +// generate 가 깨지므로, 해당 폴더를 실제로 둔 모듈만 켠다. // import ProjectDescription public extension Project { - static func configure( - moduleType: ModuleType, + static func makeModule( name: String = Environment.appName, bundleId: String, - platform: Platform = .iOS, + platform _: Platform = .iOS, product: Product = .staticFramework, packages: [Package] = [], deploymentTarget: ProjectDescription.DeploymentTargets = Environment.deploymentTarget, @@ -21,87 +21,162 @@ public extension Project { settings: ProjectDescription.Settings, scripts: [ProjectDescription.TargetScript] = [], dependencies: [ProjectDescription.TargetDependency] = [], - interfaceDependencies: [ProjectDescription.TargetDependency] = [], - testingDependencies: [ProjectDescription.TargetDependency] = [], - sources: ProjectDescription.SourceFilesList = ["Sources/**"], + testDependencies: [ProjectDescription.TargetDependency] = [], resources: ProjectDescription.ResourceFileElements? = nil, infoPlist: ProjectDescription.InfoPlist = .default, entitlements: ProjectDescription.Entitlements? = nil, schemes: [ProjectDescription.Scheme] = [], hasTests: Bool = false, + hasInterface: Bool = false, + interfaceDependencies: [ProjectDescription.TargetDependency] = [], + hasTesting: Bool = false, + testingDependencies: [ProjectDescription.TargetDependency] = [], + hasDemo: Bool = false, + demoDependencies: [ProjectDescription.TargetDependency] = [], demoDisplayName: String? = nil ) -> Project { - switch moduleType { - case .app: - return configureApp( - name: name, - bundleId: bundleId, - platform: platform, - product: product, - packages: packages, - deploymentTarget: deploymentTarget, + let interfaceTargetName = "\(name)Interface" + let testingTargetName = "\(name)Testing" + let demoTargetName = "\(name)Demo" + + var targets: [Target] = [] + + if hasInterface { + targets.append(.target( + name: interfaceTargetName, destinations: destinations, - settings: settings, - scripts: scripts, - dependencies: dependencies, - sources: sources, - resources: resources, - infoPlist: infoPlist, - entitlements: entitlements, - schemes: schemes, - hasTests: hasTests - ) - case let .module(name): - return configureModule( - name: name, - bundleId: bundleId, - platform: platform, product: product, - packages: packages, - deploymentTarget: deploymentTarget, + bundleId: "\(bundleId).Interface", + deploymentTargets: deploymentTarget, + infoPlist: .default, + buildableFolders: ["Interface"], + dependencies: interfaceDependencies, + settings: suppressWarningsSettings + )) + } + + targets.append(.target( + name: name, + destinations: destinations, + product: product, + bundleId: bundleId, + deploymentTargets: deploymentTarget, + infoPlist: infoPlist, + buildableFolders: resources != nil ? ["Sources", "Resources"] : ["Sources"], + entitlements: entitlements, + scripts: scripts, + dependencies: (hasInterface ? [.target(name: interfaceTargetName)] : []) + dependencies, + settings: suppressWarningsSettings + )) + + // Testing: Interface 를 구현한 목·픽스처. 테스트 타깃만 이걸 참조한다. + if hasTesting { + targets.append(.target( + name: testingTargetName, destinations: destinations, - settings: settings, - scripts: scripts, - dependencies: dependencies, - sources: sources, - resources: resources, - infoPlist: infoPlist, - entitlements: entitlements, - schemes: schemes, - hasTests: hasTests, - demoDisplayName: demoDisplayName - ) - case let .feature(module): - return configureFeature( - name: module.rawValue, - bundleId: bundleId, - platform: platform, product: product, - deploymentTarget: deploymentTarget, + bundleId: "\(bundleId).Testing", + deploymentTargets: deploymentTarget, + infoPlist: .default, + buildableFolders: ["Testing"], + dependencies: (hasInterface ? [.target(name: interfaceTargetName)] : []) + + [.target(name: name)] + + testingDependencies, + settings: suppressWarningsSettings + )) + } + + // Demo: 앱 전체를 빌드하지 않고 모듈 하나만 시뮬레이터에서 확인하는 용도. + if hasDemo { + targets.append(.target( + name: demoTargetName, destinations: destinations, - settings: settings, - interfaceDependencies: interfaceDependencies, - dependencies: dependencies, - testingDependencies: testingDependencies, - resources: resources, - schemes: schemes - ) - case let .microModule(name): - return configureFeature( + product: .app, + bundleId: "\(bundleId).Demo", + deploymentTargets: deploymentTarget, + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": [:], + "CFBundleDisplayName": .string(demoDisplayName ?? demoTargetName), + ]), + buildableFolders: ["Demo"], + dependencies: [.target(name: name)] + demoDependencies, + settings: suppressWarningsSettings + )) + } + + if hasTests { + targets.append(makeTestsTarget( name: name, bundleId: bundleId, - platform: platform, - product: product, - deploymentTarget: deploymentTarget, destinations: destinations, - settings: settings, - interfaceDependencies: interfaceDependencies, - dependencies: dependencies, - testingDependencies: testingDependencies, - resources: resources, - schemes: schemes - ) + deploymentTarget: deploymentTarget, + // Testing 이 있으면 테스트가 그 목을 그대로 쓴다. + dependencies: [.target(name: name)] + + testDependencies + + (hasTesting ? [.target(name: testingTargetName)] : []) + )) + } + + var allSchemes = schemes + if hasDemo { + // Demo 가 있으면 자동 스킴이 구현·Demo 를 한 BuildAction 으로 묶으므로 직접 나눈 스킴만 쓴다. + allSchemes.append(contentsOf: [ + .module(name: name, hasTests: hasTests), + .demo(name: demoTargetName), + ]) } + + return Project( + name: name, + options: .options( + automaticSchemesOptions: hasDemo ? .disabled : .enabled(codeCoverageEnabled: true), + defaultKnownRegions: ["en", "ko"], + developmentRegion: "ko" + ), + packages: packages, + settings: settings.injectingModuleConfigurationsIfNeeded(), + targets: targets, + schemes: allSchemes, + fileHeaderTemplate: .default + ) + } + + static func makeAppModule( + name: String = Environment.appName, + bundleId: String, + platform: Platform = .iOS, + product: Product = .app, + packages: [Package] = [], + deploymentTarget: ProjectDescription.DeploymentTargets = Environment.deploymentTarget, + destinations: ProjectDescription.Destinations = Environment.deploymentDestination, + settings: ProjectDescription.Settings, + scripts: [ProjectDescription.TargetScript] = [], + dependencies: [ProjectDescription.TargetDependency] = [], + sources: ProjectDescription.SourceFilesList = ["Sources/**"], + resources: ProjectDescription.ResourceFileElements? = nil, + infoPlist: ProjectDescription.InfoPlist = .default, + entitlements: ProjectDescription.Entitlements? = nil, + schemes: [ProjectDescription.Scheme] = [], + hasTests: Bool = false + ) -> Project { + return configureApp( + name: name, + bundleId: bundleId, + platform: platform, + product: product, + packages: packages, + deploymentTarget: deploymentTarget, + destinations: destinations, + settings: settings, + scripts: scripts, + dependencies: dependencies, + sources: sources, + resources: resources, + infoPlist: infoPlist, + entitlements: entitlements, + schemes: schemes, + hasTests: hasTests + ) } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift index af96f404..f377f460 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift @@ -27,7 +27,7 @@ public enum ProjectConfig { // MARK: - 🔧 기타 설정 public static let bundleIdPrefix = "io.Picke.co" - public static let teamId = "N94CS4N6VR" + public static let teamId = "3UFKCXVTN4" public static let deploymentTarget: ProjectDescription.DeploymentTargets = .iOS("17.0") public static let deploymentDestination: ProjectDescription.Destinations = [.iPhone] public static let appVersion = "1.0.0" diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/TargetDependency+Presentation.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/TargetDependency+Presentation.swift deleted file mode 100644 index 233ad323..00000000 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/TargetDependency+Presentation.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// TargetDependency+Presentation.swift -// ProjectTemplatePlugin -// -// Presentation micro-feature 의존성 경로 helper. -// Project.configure(.feature(...)) 와 같은 PresentationFeatureModule catalog 를 사용한다. -// - -import ProjectDescription - -public extension ProjectDescription.Path { - static var Presentation: Self { - return .relativeToRoot("Projects/Presentation") - } - - static func Presentation(implementation module: PresentationFeatureModule) -> Self { - return .relativeToRoot("Projects/Presentation/\(module.rawValue)") - } - - static func Presentation( - _ module: PresentationFeatureModule, - _ target: ModuleTarget - ) -> Self { - switch target { - case .interface: - return .relativeToRoot("Projects/Presentation/\(module.rawValue)/Interface") - case .implementation: - return .Presentation(implementation: module) - case .testing: - return .relativeToRoot("Projects/Presentation/\(module.rawValue)/Testing") - } - } -} - -public extension TargetDependency { - static func Presentation( - _ module: PresentationFeatureModule, - _ target: ModuleTarget = .interface - ) -> Self { - let targetName = switch target { - case .interface: - "\(module.rawValue)Interface" - case .implementation: - module.rawValue - case .testing: - "\(module.rawValue)Testing" - } - - return .project(target: targetName, path: .Presentation(module, target)) - } - - static func Presentation(implements module: PresentationFeatureModule) -> Self { - return .Presentation(module, .implementation) - } -} diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Scheme/Scheme+Module.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Scheme/Scheme+Module.swift new file mode 100644 index 00000000..99164ed8 --- /dev/null +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Scheme/Scheme+Module.swift @@ -0,0 +1,45 @@ +// +// Scheme+Module.swift +// ProjectTemplatePlugin +// +// Demo 가 있는 모듈의 구현/테스트 스킴과 Demo 실행 스킴을 분리한다. +// + +import ProjectDescription + +public extension Scheme { + static func module(name: String, hasTests: Bool) -> Scheme { + makeModuleScheme( + name: name, + target: name, + testTargets: hasTests ? ["\(name)Tests"] : [] + ) + } + + static func demo(name: String) -> Scheme { + makeModuleScheme(name: name, target: name) + } + + private static func makeModuleScheme( + name: String, + target: String, + testTargets: [TestableTarget] = [] + ) -> Scheme { + return .scheme( + name: name, + shared: true, + buildAction: .buildAction(targets: [.target(target)]), + testAction: testTargets.isEmpty + ? nil + : .targets( + testTargets, + configuration: .stage, + options: .options(coverage: true) + ), + runAction: .runAction(configuration: .stage), + archiveAction: .archiveAction(configuration: .stage), + profileAction: .profileAction(configuration: .stage), + analyzeAction: .analyzeAction(configuration: .stage) + ) + } +} diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift index be4a93ba..d205cad7 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift @@ -65,7 +65,7 @@ extension Settings { commonSettings( appName: Project.Environment.appStageName, displayName: Project.Environment.appName, - provisioningProfile: "match AppStore \(Project.Environment.bundlePrefix)", + provisioningProfile: "match Development \(Project.Environment.bundlePrefix)", setSkipInstall: false ), xcconfig: .path(.stage) @@ -93,7 +93,9 @@ extension Settings { xcconfig: .path(.prod) ), - ], defaultSettings: .recommended + // Tuist 의 recommended 기본값은 타겟 레벨에 CODE_SIGN_IDENTITY = "iPhone Developer" 를 주입해 + // base 설정을 덮는다. match 가 발급하는 건 Apple Development 이므로 이 키만 제외한다. + ], defaultSettings: .recommended(excluding: ["CODE_SIGN_IDENTITY"]) ) public static func appBaseSetting(appName: String) -> Settings { diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift index 11e16cef..e03e8dc5 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift @@ -10,97 +10,99 @@ import ProjectDescription public extension SettingsDictionary { func setProductBundleIdentifier(_ value: String = "com.iOS$(BUNDLE_ID_SUFFIX)") -> SettingsDictionary { - return self.merging(["PRODUCT_BUNDLE_IDENTIFIER": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["PRODUCT_BUNDLE_IDENTIFIER": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setProductName(_ value: String) -> SettingsDictionary { - return self.merging(["PRODUCT_NAME": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["PRODUCT_NAME": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setCFBundleDisplayName(_ value: String) -> SettingsDictionary { - return self.merging(["CFBundleDisplayName": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["CFBundleDisplayName": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setMarketingVersion(_ value: String) -> SettingsDictionary { - return self.merging(["MARKETING_VERSION": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["MARKETING_VERSION": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setASAuthenticationServicesEnabled(_ value: String = "YES") -> SettingsDictionary { - return self.merging(["AS_AUTHENTICATION_SERVICES_ENABLED": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["AS_AUTHENTICATION_SERVICES_ENABLED": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setPushNotificationsEnabled(_ value: String = "YES") -> SettingsDictionary { - return self.merging(["PUSH_NOTIFICATIONS_ENABLED": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["PUSH_NOTIFICATIONS_ENABLED": SettingValue(stringLiteral: value)]) { _, new in new } } - - func setEnableBackgroundModes(_ value: String = "YES", backgroundModes: String = "remote-notification") -> SettingsDictionary { - return self.merging(["ENABLE_BACKGROUND_MODES": SettingValue(stringLiteral: value), "BACKGROUND_MODES": SettingValue(stringLiteral: backgroundModes)]) { (_, new) in new } + + func setEnableBackgroundModes(_ value: String = "YES", + backgroundModes: String = "remote-notification") -> SettingsDictionary + { + return merging([ + "ENABLE_BACKGROUND_MODES": SettingValue(stringLiteral: value), + "BACKGROUND_MODES": SettingValue(stringLiteral: backgroundModes), + ]) { _, new in new } } - + func setArchs(_ value: String = "$(ARCHS_STANDARD)") -> SettingsDictionary { - return self.merging(["ARCHS": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["ARCHS": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setOtherLdFlags(_ value: String = "$(inherited) -ObjC") -> SettingsDictionary { - return self.merging(["OTHER_LDFLAGS": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["OTHER_LDFLAGS": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setCurrentProjectVersion(_ value: String) -> SettingsDictionary { - return self.merging(["CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: value)]) { _, new in new } } - - func setCodeSignIdentity(_ value: String = "iPhone Developer") -> SettingsDictionary { - return self.merging(["CODE_SIGN_IDENTITY": SettingValue(stringLiteral: value)]) { (_, new) in new } + + func setCodeSignIdentity(_ value: String = "Apple Development") -> SettingsDictionary { + return merging(["CODE_SIGN_IDENTITY": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setCodeSignStyle(_ value: String = "Manual") -> SettingsDictionary { - return self.merging(["CODE_SIGN_STYLE": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["CODE_SIGN_STYLE": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setVersioningSystem(_ value: String = "apple-generic") -> SettingsDictionary { - return self.merging(["VERSIONING_SYSTEM": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["VERSIONING_SYSTEM": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setDebugInformationFormat(_ value: String = "DWARF with dSYM File") -> SettingsDictionary { - return self.merging(["DEBUG_INFORMATION_FORMAT": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["DEBUG_INFORMATION_FORMAT": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setStripStyle(_ value: String = "non-global") -> SettingsDictionary { - return self.merging(["STRIP_STYLE": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["STRIP_STYLE": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setProvisioningProfileSpecifier(_ value: String) -> SettingsDictionary { - return self.merging(["PROVISIONING_PROFILE_SPECIFIER": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["PROVISIONING_PROFILE_SPECIFIER": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setSwiftVersion(_ value: String) -> SettingsDictionary { - return self.merging(["SWIFT_VERSION": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["SWIFT_VERSION": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setDevelopmentTeam(_ value: String) -> SettingsDictionary { - return self.merging(["DEVELOPMENT_TEAM": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["DEVELOPMENT_TEAM": SettingValue(stringLiteral: value)]) { _, new in new } } - + func setSkipInstall(_ value: Bool = false) -> SettingsDictionary { - return self.merging(["SKIP_INSTALL": SettingValue(stringLiteral: value ? "YES" : "NO")]) + return merging(["SKIP_INSTALL": SettingValue(stringLiteral: value ? "YES" : "NO")]) } - + func setExplicitlyBuiltModules(_ value: Bool = true) -> SettingsDictionary { - return self.merging(["EXPLICITLY_BUILT_MODULES": SettingValue(stringLiteral: value ? "YES" : "NO")]) + return merging(["EXPLICITLY_BUILT_MODULES": SettingValue(stringLiteral: value ? "YES" : "NO")]) } - + /// 기본 로케일을 한국어(ko)로 설정하는 메서드 func setCFBundleDevelopmentRegion(_ value: String = "ko") -> SettingsDictionary { - return self.merging(["CFBundleDevelopmentRegion": SettingValue(stringLiteral: value)]) { (_, new) in new } + return merging(["CFBundleDevelopmentRegion": SettingValue(stringLiteral: value)]) { _, new in new } } func setAllowNonModularIncludesInFrameworkModules(_ value: Bool) -> SettingsDictionary { let stringValue = value ? "YES" : "NO" return merging([ - "CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES": SettingValue(stringLiteral: stringValue) + "CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES": SettingValue(stringLiteral: stringValue), ]) { _, new in new } } } - - - diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index 472e597e..4c120ac2 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -27,7 +27,6 @@ public extension InfoPlist { .setAppUseExemptEncryption(value: false) .setCFBundleVersion(.appBuildVersion()) .setLSRequiresIPhoneOS(true) - .setUIAppFonts(["PretendardVariable.ttf"]) .setUIApplicationSceneManifest([ "UIApplicationSupportsMultipleScenes": true, "UISceneConfigurations": [ diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 66940e72..6b0f4bd8 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -5,33 +5,29 @@ import ProjectTemplatePlugin private let appName = Project.Environment.appName -let project = Project.configure( - moduleType: .app, +let project = Project.makeAppModule( name: appName, bundleId: .mainBundleID(), product: .app, settings: .appMainSetting, scripts: [.SentryUploadString], dependencies: [ - .Presentation(implements: .Presentation), - .Domain(implements: .Domain), - .Data(implements: .Data), - .Network(implements: .NetworkModule), - .Shared(implements: .Shared), - .Shared(implements: .AdKit), // 앱 시작 전면 팝업 광고 + // 화면·도메인·데이터 구현은 각 레이어의 조립 경계 하나로 들어온다. + .featureAssembly, + .feature(.notification, .interface), + .domainAssembly, + .serviceAssembly, + .core(.storage, .interface), + // 외부 SDK 부팅은 이 모듈만 안다. + .service(.config), + // Splash 가 App 으로 올라오며 애니메이션 에셋을 직접 쓴다. + .ui(.animation), .SPM.googleMobileAds, - .SPM.firebaseCrashlytics, - .SPM.mixpanel, - .SPM.mixpanelSessionReplay, .SPM.kingfisher, - .SPM.sdwebImageCore, - .SPM.sentrySwiftUI, - ], - sources: ["Sources/**"], resources: ["Resources/**"], infoPlist: .appInfoPlist, entitlements: .file(path: "../../Entitlements/Picke.entitlements"), schemes: Scheme.appSchemes(appName: appName), - hasTests: false + hasTests: true ) diff --git a/Projects/App/Sources/Application/AppDelegate+Configure.swift b/Projects/App/Sources/Application/AppDelegate+Configure.swift new file mode 100644 index 00000000..e16e45a2 --- /dev/null +++ b/Projects/App/Sources/Application/AppDelegate+Configure.swift @@ -0,0 +1,118 @@ +// +// AppDelegate+Configure.swift +// Picke +// + +import AppTrackingTransparency +import GoogleMobileAds +import PickeCoreLogger +import UIKit +import UserNotifications + +import DomainAssembly +import PickeConfig +import PickeDesignKit +import ServiceAssembly + +extension AppDelegate { + /// 기동 초기화 진입점. 순서에 의미가 있다. + /// 외부 SDK 부팅은 Firebase 를 PickeConfig 가, Sentry·Mixpanel 을 PickeAnalytics 가 맡는다. + func configure() { + configureFonts() + FirebaseConfiguration.configure() + PickeAnalyticsConfiguration.configure() + configureAdMob() + requestTrackingAuthorizationWhenActive() + configurePushNotifications() + configureImageDownloader() + } + + // MARK: - 폰트 + + func configureFonts() { + PretendardFontFamily.registerFonts() + } + + // MARK: - AdMob + + func configureAdMob() { + registerAdTestDevices() + MobileAds.shared.start() + } + + /// 개발/QA 빌드의 AdMob 무효 트래픽 방지 — 내부 기기를 테스트 디바이스로 등록한다. + /// + /// 광고 단위 ID 는 Stage/Prod/Release 전 환경 동일(프로덕션)이라 ID 분리로는 막을 수 없다. + /// 대신 AdMob 공식 방식인 테스트 디바이스 등록을 쓰면 프로덕션 ID 그대로도 내부 기기엔 + /// 테스트 광고가 노출되고, 그 노출·클릭은 실적에 집계되지 않는다. + /// + /// 시뮬레이터는 SDK 가 자동으로 테스트 기기로 취급한다. 실기기는 앱을 한 번 실행하면 콘솔에 + /// `To get test ads on this device, set: testDeviceIdentifiers = @[ @"<해시>" ]` 가 찍히므로 + /// 그 해시를 아래 배열에 추가하면 된다. + func registerAdTestDevices() { + #if DEBUG || STAGE + // 팀 내부 실기기 해시 — 콘솔 로그를 보고 추가할 것. + let internalDeviceIdentifiers: [String] = [] + + MobileAds.shared.requestConfiguration.testDeviceIdentifiers = internalDeviceIdentifiers + PickeLogger.debug("[AdMob] 테스트 디바이스 등록 — 내부 기기 \(internalDeviceIdentifiers.count)대 + 시뮬레이터", category: .app) + #endif + } + + // MARK: - 이미지 다운로더 + + /// Store 수명 밖에서 도는 인프라라 기동 시 한 번만 설정한다. + func configureImageDownloader() { + KingfisherConfigurator.configureAuthorizedDownloader( + storage: StorageAssembly.secureStorage() + ) + } + + // MARK: - 푸시 알림 + + func configurePushNotifications() { + let center = UNUserNotificationCenter.current() + center.delegate = self + + center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error { + PickeLogger.error("[Push] 권한 요청 실패: \(error.localizedDescription)", category: .app) + return + } + guard granted else { + PickeLogger.debug("[Push] 알림 권한 거부됨", category: .app) + return + } + Task { @MainActor in + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + // MARK: - 광고 식별자(ATT) + + /// ATT(광고 식별자) 권한 요청. AdFit·AdMob 모두 IDFA 가 있어야 맞춤 광고를 집행한다. + /// + /// didFinishLaunching 시점엔 앱이 아직 active 가 아니라 팝업이 뜨지 않고 `.notDetermined` + /// 로 즉시 반환된다(그러면 다시 물어볼 기회가 사라진다). 그래서 최초 active 알림을 한 번 + /// 받은 뒤 요청한다. + func requestTrackingAuthorizationWhenActive() { + guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return } + + trackingAuthorizationObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + if let observer = self.trackingAuthorizationObserver { + NotificationCenter.default.removeObserver(observer) + self.trackingAuthorizationObserver = nil + } + let status = await ATTrackingManager.requestTrackingAuthorization() + PickeLogger.debug("[ATT] 추적 권한 상태: \(status.rawValue)", category: .app) + } + } + } +} diff --git a/Projects/App/Sources/Application/Delegate/AppDelegate+Push.swift b/Projects/App/Sources/Application/AppDelegate+Push.swift similarity index 58% rename from Projects/App/Sources/Application/Delegate/AppDelegate+Push.swift rename to Projects/App/Sources/Application/AppDelegate+Push.swift index 740cfb69..3a7ef9ad 100644 --- a/Projects/App/Sources/Application/Delegate/AppDelegate+Push.swift +++ b/Projects/App/Sources/Application/AppDelegate+Push.swift @@ -3,32 +3,15 @@ // Picke // -import LogMacro +import PickeCoreLogger import UIKit import UserNotifications -import WeaveDI -import Domain +import DomainAssembly +import PickeStorageInterface +import ServiceAssembly extension AppDelegate { - func configurePushNotifications() { - let center = UNUserNotificationCenter.current() - center.delegate = self - - center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in - if let error { - #logError("[Push] 권한 요청 실패: \(error.localizedDescription)") - return - } - guard granted else { - #logDebug("[Push] 알림 권한 거부됨") - return - } - Task { @MainActor in - UIApplication.shared.registerForRemoteNotifications() - } - } - } // APNs 디바이스 토큰 수신 → 저장 후 로그인 상태면 서버 등록. func application( @@ -36,19 +19,26 @@ extension AppDelegate { didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() - PushTokenStore.current = tokenString - #logDebug("[Push] APNs 토큰 수신: \(tokenString.prefix(12))…") + PushRegistrationService.current = tokenString + PickeLogger.debug("[Push] APNs 토큰 수신: \(tokenString.prefix(12))…", category: .app) + + Task { + guard await NetworkContainer.authService.isLoggedIn else { return } + let result = await Result { + try await PushRegistrationService.register() + } - guard let keychainManager = UnifiedDI.resolve(KeychainManaging.self), - keychainManager.accessToken()?.isEmpty == false else { return } - Task { await PushTokenStore.register() } + if case let .failure(error) = result { + PickeLogger.error("[Push] 디바이스 등록 실패: \(error.localizedDescription)", category: .network) + } + } } func application( _: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { - #logError("[Push] APNs 등록 실패: \(error.localizedDescription)") + PickeLogger.error("[Push] APNs 등록 실패: \(error.localizedDescription)", category: .app) } } @@ -71,7 +61,7 @@ extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo - PushDeeplinkBridge.handlePushPayload(userInfo) + AppDeeplinkBridge.handlePushPayload(userInfo) completionHandler() } } diff --git a/Projects/App/Sources/Application/Delegate/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift similarity index 91% rename from Projects/App/Sources/Application/Delegate/AppDelegate.swift rename to Projects/App/Sources/Application/AppDelegate.swift index 146daf3d..aca7b60a 100644 --- a/Projects/App/Sources/Application/Delegate/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -6,7 +6,6 @@ import UIKit class AppDelegate: UIResponder, UIApplicationDelegate { - let mixPanelKey = Bundle.main.object(forInfoDictionaryKey: "MIXPANEL_TOKEN") as? String /// ATT 팝업을 앱 active 이후로 미루기 위한 1회성 옵저버. /// 저장 프로퍼티는 확장에 둘 수 없어 여기 남으며, AppDelegate+Tracking 에서 쓰므로 internal 이다. diff --git a/Projects/App/Sources/Application/Configuration/KingfisherConfigurator.swift b/Projects/App/Sources/Application/Configuration/KingfisherConfigurator.swift deleted file mode 100644 index fa15ba38..00000000 --- a/Projects/App/Sources/Application/Configuration/KingfisherConfigurator.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// KingfisherConfigurator.swift -// App -// - -import Foundation - -import Kingfisher - -import Domain - -enum KingfisherConfigurator { - /// 보호 이미지 (picke 백엔드 `/api/v1/resources/...`) 에만 Bearer 토큰을 첨부한다. - /// 그 외 외부 호스트 (picsum.photos / 카카오 CDN 등) 로는 토큰을 절대 보내지 않는다. - private static let protectedHostSuffixes: Set = [ - "picke.store", - "dev.picke.store", - ] - - static func configureAuthorizedDownloader( - keychainManager: KeychainManaging - ) { - let modifier = AnyModifier { request in - var req = request - - guard - let url = req.url, - let host = url.host?.lowercased(), - protectedHostSuffixes.contains(where: { host == $0 || host.hasSuffix(".\($0)") }), - url.path.hasPrefix("/api/"), - let token = keychainManager.accessToken(), !token.isEmpty - else { - return req - } - - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return req - } - - KingfisherManager.shared.defaultOptions = [ - .requestModifier(modifier), - ] - } -} diff --git a/Projects/App/Sources/Application/Configuration/PushTokenStore.swift b/Projects/App/Sources/Application/Configuration/PushTokenStore.swift deleted file mode 100644 index 4ee0e3a4..00000000 --- a/Projects/App/Sources/Application/Configuration/PushTokenStore.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PushTokenStore.swift -// Picke -// - -import Foundation - -import Entity -import LogMacro -import UseCase - -enum PushTokenStore { - static var current: String? { - get { DeviceTokenStorage.token } - set { DeviceTokenStorage.token = newValue } - } - - /// 로그인 직후 / 토큰 갱신 시 디바이스 등록. - static func register() async { - guard let token = current, !token.isEmpty else { return } - do { - try await DeviceUseCaseImpl().registerDevice(fcmToken: token, platform: .ios) - #logDebug("[Push] 디바이스 등록 완료") - } catch { - #logError("[Push] 디바이스 등록 실패: \(error.localizedDescription)") - } - } - - /// 로그아웃 / 탈퇴 시 디바이스 해제 (Keychain 초기화 전에 호출). - static func unregister() async { - guard let token = current, !token.isEmpty else { return } - do { - try await DeviceUseCaseImpl().unregisterDevice(fcmToken: token) - #logDebug("[Push] 디바이스 해제 완료") - } catch { - #logError("[Push] 디바이스 해제 실패: \(error.localizedDescription)") - } - } -} diff --git a/Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift b/Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift deleted file mode 100644 index 69550bda..00000000 --- a/Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// PushDeeplinkBridge.swift -// Picke -// - -import Foundation - -import Domain -import LogMacro - -enum PushDeeplinkBridge { - static let pendingKey = "PickePendingDeeplink" - - /// 푸시 payload(userInfo) 를 딥링크로 변환 → 대기열 저장 + 브로드캐스트. - static func handlePushPayload(_ userInfo: [AnyHashable: Any]) { - guard let deeplink = PickeDeeplinkParser.parse(pushPayload: userInfo) else { - #logDebug("[Deeplink] 처리 가능한 푸시 페이로드 없음") - return - } - broadcast(deeplink) - } - - /// 커스텀 스킴(picke://...) / 유니버설 링크 URL 로 앱이 열렸을 때. - static func handleURL(_ url: URL) { - guard let deeplink = PickeDeeplinkParser.parse(urlString: url.absoluteString) else { - #logDebug("[Deeplink] 처리 불가 URL: \(url.absoluteString)") - return - } - broadcast(deeplink) - } - - /// 딥링크를 대기열에 저장하고 즉시 알림. - static func broadcast(_ deeplink: PickeDeeplink) { - UserDefaults.standard.set(deeplink.encoded, forKey: pendingKey) - NotificationCenter.default.post( - name: .pickeDeeplink, - object: nil, - userInfo: ["deeplink": deeplink.encoded] - ) - #logDebug("[Deeplink] 브로드캐스트: \(deeplink.encoded)") - } - - /// AppReducer 가 라우팅을 끝낸 뒤 대기열 비움. - static func consumePending() -> PickeDeeplink? { - guard let encoded = UserDefaults.standard.string(forKey: pendingKey) else { return nil } - UserDefaults.standard.removeObject(forKey: pendingKey) - return PickeDeeplinkParser.parse(urlString: encoded) - } -} diff --git a/Projects/App/Sources/Application/Delegate/AppDelegate+Configure.swift b/Projects/App/Sources/Application/Delegate/AppDelegate+Configure.swift deleted file mode 100644 index 9ac185be..00000000 --- a/Projects/App/Sources/Application/Delegate/AppDelegate+Configure.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// AppDelegate+Configure.swift -// Picke -// - -import Firebase -import GoogleMobileAds -import LogMacro -import Mixpanel -import MixpanelSessionReplay -import UIKit -import WeaveDI - -import Domain - -extension AppDelegate { - /// 기동 초기화 진입점. 순서에 의미가 있다. - /// Sentry 를 가장 먼저 올려야 이후 초기화 중 발생한 크래시까지 포착된다. - func configure() { - configureSentry() - configureFirebase() - configureMixpanel() - configureAdMob() - requestTrackingAuthorizationWhenActive() - configurePushNotifications() - configureDependencies() - } - - // MARK: - Firebase - - func configureFirebase() { - FirebaseApp.configure() - } - - // MARK: - Mixpanel - - func configureMixpanel() { - #logDebug( - "Mixpanel initialize", - [ - "token_exists": !(mixPanelKey?.isEmpty ?? true), - "token_prefix": String((mixPanelKey ?? "").prefix(6)), - ] - ) - Mixpanel.initialize(token: mixPanelKey ?? "", trackAutomaticEvents: true) - - // 모든 이벤트에 자동 첨부되는 공통 슈퍼 프로퍼티(플랫폼/버전). is_logged_in 은 로그인/로그아웃이 관리. - let info = Bundle.main.infoDictionary - Mixpanel.mainInstance().registerSuperProperties([ - "os_type": "ios", - "app_version": (info?["CFBundleShortVersionString"] as? String) ?? "", - "build": (info?["CFBundleVersion"] as? String) ?? "", - ]) - - initializeMixpanelSessionReplay() - } - - /// NOTE: MixpanelSessionReplay 1.4.0의 _UIReparentingView swizzling이 - /// SwiftUI UIHostingController.view에 적용되며 콘솔 경고가 출력될 수 있음 (기능 영향 없음). - /// SDK 측 SwiftUI 호환성 개선 시 경고 자동 해소 예정. - private func initializeMixpanelSessionReplay() { - guard !(mixPanelKey?.isEmpty ?? true) else { return } - - var config = MPSessionReplayConfig(wifiOnly: false) - config.enableSessionReplayOniOS26AndLater = true - - MPSessionReplay.initialize( - token: Mixpanel.mainInstance().apiToken, - distinctId: Mixpanel.mainInstance().distinctId, - config: config - ) - } - - // MARK: - AdMob - - func configureAdMob() { - registerAdTestDevices() - MobileAds.shared.start() - } - - /// 개발/QA 빌드의 AdMob 무효 트래픽 방지 — 내부 기기를 테스트 디바이스로 등록한다. - /// - /// 광고 단위 ID 는 Stage/Prod/Release 전 환경 동일(프로덕션)이라 ID 분리로는 막을 수 없다. - /// 대신 AdMob 공식 방식인 테스트 디바이스 등록을 쓰면 프로덕션 ID 그대로도 내부 기기엔 - /// 테스트 광고가 노출되고, 그 노출·클릭은 실적에 집계되지 않는다. - /// - /// 시뮬레이터는 SDK 가 자동으로 테스트 기기로 취급한다. 실기기는 앱을 한 번 실행하면 콘솔에 - /// `To get test ads on this device, set: testDeviceIdentifiers = @[ @"<해시>" ]` 가 찍히므로 - /// 그 해시를 아래 배열에 추가하면 된다. - func registerAdTestDevices() { - #if DEBUG || STAGE - // 팀 내부 실기기 해시 — 콘솔 로그를 보고 추가할 것. - let internalDeviceIdentifiers: [String] = [] - - MobileAds.shared.requestConfiguration.testDeviceIdentifiers = internalDeviceIdentifiers - #logDebug("[AdMob] 테스트 디바이스 등록 — 내부 기기 \(internalDeviceIdentifiers.count)대 + 시뮬레이터") - #endif - } - - // MARK: - DI - - func configureDependencies() { - WeaveDI.Container.bootstrapInTask { @DIContainerActor _ in - await AppDIManager.shared.registerDefaultDependencies() - - // Kingfisher 글로벌 requestModifier 등록 — DI 등록 직후라 KeychainManaging resolve 보장 - if let keychainManager = UnifiedDI.resolve(KeychainManaging.self) { - await MainActor.run { - KingfisherConfigurator.configureAuthorizedDownloader( - keychainManager: keychainManager - ) - } - } - } - } -} diff --git a/Projects/App/Sources/Application/Delegate/AppDelegate+Tracking.swift b/Projects/App/Sources/Application/Delegate/AppDelegate+Tracking.swift deleted file mode 100644 index 3cf41d44..00000000 --- a/Projects/App/Sources/Application/Delegate/AppDelegate+Tracking.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AppDelegate+Tracking.swift -// Picke -// - -import AppTrackingTransparency -import LogMacro -import UIKit - -extension AppDelegate { - /// ATT(광고 식별자) 권한 요청. AdFit·AdMob 모두 IDFA 가 있어야 맞춤 광고를 집행한다. - /// - /// didFinishLaunching 시점엔 앱이 아직 active 가 아니라 팝업이 뜨지 않고 `.notDetermined` - /// 로 즉시 반환된다(그러면 다시 물어볼 기회가 사라진다). 그래서 최초 active 알림을 한 번 - /// 받은 뒤 요청한다. - func requestTrackingAuthorizationWhenActive() { - guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return } - - trackingAuthorizationObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - guard let self else { return } - if let observer = self.trackingAuthorizationObserver { - NotificationCenter.default.removeObserver(observer) - self.trackingAuthorizationObserver = nil - } - let status = await ATTrackingManager.requestTrackingAuthorization() - #logDebug("[ATT] 추적 권한 상태: \(status.rawValue)") - } - } - } -} diff --git a/Projects/App/Sources/Application/KingfisherConfigurator.swift b/Projects/App/Sources/Application/KingfisherConfigurator.swift new file mode 100644 index 00000000..f9e849e7 --- /dev/null +++ b/Projects/App/Sources/Application/KingfisherConfigurator.swift @@ -0,0 +1,85 @@ +// +// KingfisherConfigurator.swift +// App +// + +import Foundation + +import Kingfisher +import PickeNetwork +import PickeStorageInterface + +import DomainAssembly + +enum KingfisherConfigurator { + private static let telemetryDelegate = KingfisherTelemetryDelegate() + + /// 보호 이미지 (picke 백엔드 `/api/v1/resources/...`) 에만 Bearer 토큰을 첨부한다. + /// 그 외 외부 호스트 (picsum.photos / 카카오 CDN 등) 로는 토큰을 절대 보내지 않는다. + private static let protectedHostSuffixes: Set = [ + "picke.store", + "dev.picke.store", + ] + + static func configureAuthorizedDownloader( + storage: any SecureStorage + ) { + let modifier = AnyModifier { request in + var req = request + + guard + let url = req.url, + let host = url.host?.lowercased(), + protectedHostSuffixes.contains(where: { host == $0 || host.hasSuffix(".\($0)") }), + url.path.hasPrefix("/api/"), + let token = try? storage.load(.accessToken), !token.isEmpty + else { + return req + } + + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return req + } + + KingfisherManager.shared.defaultOptions = [ + .requestModifier(modifier), + ] + KingfisherManager.shared.downloader.delegate = telemetryDelegate + } +} + +private final class KingfisherTelemetryDelegate: ImageDownloaderDelegate, @unchecked Sendable { + private let lock = NSLock() + private var startedAtByURL: [URL: Date] = [:] + + func imageDownloader( + _: ImageDownloader, + willDownloadImageForURL url: URL, + with _: URLRequest? + ) { + lock.withLock { + startedAtByURL[url] = Date() + } + } + + func imageDownloader( + _: ImageDownloader, + didFinishDownloadingImageForURL url: URL, + with response: URLResponse?, + error: Error? + ) { + let startedAt = lock.withLock { + startedAtByURL.removeValue(forKey: url) ?? Date() + } + NetworkTelemetry.shared.record( + NetworkTelemetryEvent( + source: "kingfisher", + method: "GET", + url: url, + statusCode: (response as? HTTPURLResponse)?.statusCode, + duration: Date().timeIntervalSince(startedAt), + isSuccess: error == nil + ) + ) + } +} diff --git a/Projects/App/Sources/Application/Entry/PickeApp.swift b/Projects/App/Sources/Application/PickeApp.swift similarity index 85% rename from Projects/App/Sources/Application/Entry/PickeApp.swift rename to Projects/App/Sources/Application/PickeApp.swift index 85d40183..8170f920 100644 --- a/Projects/App/Sources/Application/Entry/PickeApp.swift +++ b/Projects/App/Sources/Application/PickeApp.swift @@ -1,5 +1,5 @@ import ComposableArchitecture -import SentrySwiftUI +import ServiceAssembly import SwiftUI @main @@ -20,12 +20,12 @@ struct PickeApp: App { #endif } - SentryTracedView("AppRoot") { + PickeTracedView("AppRoot") { AppView(store: store) } .onOpenURL { url in // picke://... 커스텀 스킴 / 유니버설 링크 → 딥링크 라우팅. - PushDeeplinkBridge.handleURL(url) + AppDeeplinkBridge.handleURL(url) } } } diff --git a/Projects/App/Sources/Deeplink/AppDeeplinkBridge.swift b/Projects/App/Sources/Deeplink/AppDeeplinkBridge.swift new file mode 100644 index 00000000..b2369643 --- /dev/null +++ b/Projects/App/Sources/Deeplink/AppDeeplinkBridge.swift @@ -0,0 +1,45 @@ +// +// AppDeeplinkBridge.swift +// Picke +// + +import Foundation + +import PickeCoreLogger +import PickeCoreUtility + +enum AppDeeplinkBridge { + private static var pendingStore: PendingDeeplinkStore { PendingDeeplinkStore() } + + /// 푸시 payload(userInfo) 를 딥링크로 변환 → 최신 대기 요청 저장 + 브로드캐스트. + static func handlePushPayload(_ userInfo: [AnyHashable: Any]) { + guard let deeplink = PickeDeeplinkParser.parse(pushPayload: userInfo) else { + PickeLogger.debug("[Deeplink] 처리 가능한 푸시 페이로드 없음", category: .navigation) + return + } + broadcast(deeplink) + } + + /// 커스텀 스킴(picke://...) / 유니버설 링크 URL 로 앱이 열렸을 때. + static func handleURL(_ url: URL) { + guard let deeplink = PickeDeeplinkParser.parse(urlString: url.absoluteString) else { + PickeLogger.debug("[Deeplink] 처리 불가 URL: \(url.absoluteString)", category: .navigation) + return + } + broadcast(deeplink) + } + + /// 딥링크를 최신 대기 요청에 저장하고 즉시 알림. + static func broadcast(_ deeplink: PickeDeeplink) { + pendingStore.save(deeplink) + NotificationCenter.default.post( + name: .pickeDeeplink, + object: nil + ) + PickeLogger.debug("[Deeplink] 브로드캐스트: \(deeplink.encoded)", category: .navigation) + } + + static func consumePending() -> PickeDeeplink? { + return pendingStore.consume() + } +} diff --git a/Projects/App/Sources/Deeplink/PendingDeeplinkStore.swift b/Projects/App/Sources/Deeplink/PendingDeeplinkStore.swift new file mode 100644 index 00000000..c3a7b775 --- /dev/null +++ b/Projects/App/Sources/Deeplink/PendingDeeplinkStore.swift @@ -0,0 +1,20 @@ +import PickeCoreUtility +import PickeStorageInterface + +import Sharing + +/// 기존 Storage의 영속·테스트 메모리 경계를 사용해 최신 요청 하나만 보관한다. +struct PendingDeeplinkStore: Sendable { + @Shared(.pendingDeeplink) private var encoded: String? + + func save(_ deeplink: PickeDeeplink) { + $encoded.withLock { $0 = deeplink.encoded } + } + + func consume() -> PickeDeeplink? { + $encoded.withLock { value in + defer { value = nil } + return value.flatMap { PickeDeeplinkParser.parse(urlString: $0) } + } + } +} diff --git a/Projects/App/Sources/Di/AppPresentationContextProvider.swift b/Projects/App/Sources/Di/AppPresentationContextProvider.swift deleted file mode 100644 index 3d14bbd6..00000000 --- a/Projects/App/Sources/Di/AppPresentationContextProvider.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// 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 deleted file mode 100644 index f422c905..00000000 --- a/Projects/App/Sources/Di/DiRegister.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// DiRegister.swift -// Picke -// -// Created by Wonji Suh on 11/24/25. -// - -import Foundation - -import AttendanceData -import AttendanceDomainInterface -import AuthData -import AuthDomain -import BattleData -import BattleDomainInterface -import CommentData -import CommentDomain -import CommentDomainInterface -import DomainInterface -import HomeData -import HomeDomainInterface -import NetworkToken -import NotificationData -import NotificationDomainInterface -import PerspectiveData -import PerspectiveDomainInterface -import ProfileData -import Repository -import SearchData -import SearchDomainInterface -import UseCase - -import ComposableArchitecture -import WeaveDI - -/// 기본 WeaveDI 관리자 (단순하고 안정적) -@MainActor -public final class AppDIManager: Sendable { - public static let shared = AppDIManager() - - private init() {} - - /// 기본 WeaveDI 의존성 등록 (Repository만) - public func registerDefaultDependencies() { - // Repository 구현체들만 등록 - WeaveDI.builder - // 🔧 인프라 계층 (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 } - .register { HomeRepositoryImpl() as HomeInterface } - .register { BattleRepositoryImpl() as BattleInterface } - .register { CommentRepositoryImpl() as CommentInterface } - .register { PerspectiveRepositoryImpl() as PerspectiveInterface } - .register { SearchRepositoryImpl() as SearchInterface } - .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } - .register { ProfileRepositoryImpl() as ProfileInterface } - .register { NotificationRepositoryImpl() as NotificationInterface } - .register { AttendanceRepositoryImpl() as AttendanceInterface } - .register { DeviceRepositoryImpl() as DeviceInterface } - .register { AppUpdateRepositoryImpl() as AppUpdateInterface } - .register { AppUpdateUseCaseImpl() as AppUpdateUseCaseInterface } - .register { AuthUseCaseImpl() as AuthUseCaseInterface } - .register { UnifiedOAuthUseCase() as UnifiedOAuthUseCaseInterface } - // 🔐 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 } - .configure() - } -} diff --git a/Projects/App/Sources/Di/KeychainTokenProvider.swift b/Projects/App/Sources/Di/KeychainTokenProvider.swift deleted file mode 100644 index f8ebf7fa..00000000 --- a/Projects/App/Sources/Di/KeychainTokenProvider.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// KeychainTokenProvider.swift -// DDDAttendance -// -// Created by Wonji Suh on 1/2/26. -// - -import Foundation - -// 필요 모듈만 사용 -import Domain -import NetworkModule - -struct KeychainTokenProvider: TokenProviding { - private let keychainManager: KeychainManaging - - init(keychainManager: KeychainManaging) { - self.keychainManager = keychainManager - } - - func accessToken() -> String? { - keychainManager.accessToken() - } - - func saveAccessToken(_ token: String) { - keychainManager.saveAccessToken(token) - } - - func clearAccessToken() { - keychainManager.clearAccessToken() - } -} diff --git a/Projects/App/Sources/Reducer/Coordinator/Auth/AppAuthCoordinator.swift b/Projects/App/Sources/Navigation/Auth/AppAuthCoordinator.swift similarity index 99% rename from Projects/App/Sources/Reducer/Coordinator/Auth/AppAuthCoordinator.swift rename to Projects/App/Sources/Navigation/Auth/AppAuthCoordinator.swift index e75fd215..de517976 100644 --- a/Projects/App/Sources/Reducer/Coordinator/Auth/AppAuthCoordinator.swift +++ b/Projects/App/Sources/Navigation/Auth/AppAuthCoordinator.swift @@ -6,7 +6,7 @@ import Foundation import ComposableArchitecture -import Presentation +import FeatureAssembly import TCAFlow @FlowCoordinator(screen: "AppAuthScreen", navigation: true) diff --git a/Projects/App/Sources/View/Coordinator/Auth/AppAuthCoordinatorView.swift b/Projects/App/Sources/Navigation/Auth/AppAuthCoordinatorView.swift similarity index 97% rename from Projects/App/Sources/View/Coordinator/Auth/AppAuthCoordinatorView.swift rename to Projects/App/Sources/Navigation/Auth/AppAuthCoordinatorView.swift index 5ee1fe16..88df83b3 100644 --- a/Projects/App/Sources/View/Coordinator/Auth/AppAuthCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Auth/AppAuthCoordinatorView.swift @@ -6,7 +6,7 @@ import SwiftUI import ComposableArchitecture -import Presentation +import FeatureAssembly import TCAFlow public struct AppAuthCoordinatorView: View { diff --git a/Projects/App/Sources/Reducer/Coordinator/Battle/AppBattleCoordinator.swift b/Projects/App/Sources/Navigation/Battle/AppBattleCoordinator.swift similarity index 97% rename from Projects/App/Sources/Reducer/Coordinator/Battle/AppBattleCoordinator.swift rename to Projects/App/Sources/Navigation/Battle/AppBattleCoordinator.swift index 5240fcd4..90696225 100644 --- a/Projects/App/Sources/Reducer/Coordinator/Battle/AppBattleCoordinator.swift +++ b/Projects/App/Sources/Navigation/Battle/AppBattleCoordinator.swift @@ -5,11 +5,11 @@ import Foundation +import AudioPlayerServiceInterface import ComposableArchitecture -import DomainInterface +import PickeCoreUtility import PickeDesignKit -import Presentation -import Shared +import FeatureAssembly import TCAFlow @FlowCoordinator(screen: "AppBattleScreen", navigation: true) diff --git a/Projects/App/Sources/View/Coordinator/Battle/AppBattleCoordinatorView.swift b/Projects/App/Sources/Navigation/Battle/AppBattleCoordinatorView.swift similarity index 96% rename from Projects/App/Sources/View/Coordinator/Battle/AppBattleCoordinatorView.swift rename to Projects/App/Sources/Navigation/Battle/AppBattleCoordinatorView.swift index 8a6fe7f0..c1e625bb 100644 --- a/Projects/App/Sources/View/Coordinator/Battle/AppBattleCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Battle/AppBattleCoordinatorView.swift @@ -6,7 +6,7 @@ import SwiftUI import ComposableArchitecture -import Presentation +import FeatureAssembly import TCAFlow public struct AppBattleCoordinatorView: View { diff --git a/Projects/App/Sources/Reducer/Coordinator/Hifi/AppHifiCoordinator.swift b/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinator.swift similarity index 98% rename from Projects/App/Sources/Reducer/Coordinator/Hifi/AppHifiCoordinator.swift rename to Projects/App/Sources/Navigation/Hifi/AppHifiCoordinator.swift index 7f87907e..85ac3a53 100644 --- a/Projects/App/Sources/Reducer/Coordinator/Hifi/AppHifiCoordinator.swift +++ b/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinator.swift @@ -5,9 +5,9 @@ import Foundation +import AudioPlayerServiceInterface import ComposableArchitecture -import DomainInterface -import Presentation +import FeatureAssembly import TCAFlow @FlowCoordinator(screen: "AppHifiScreen", navigation: true) diff --git a/Projects/App/Sources/View/Coordinator/Hifi/AppHifiCoordinatorView.swift b/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinatorView.swift similarity index 97% rename from Projects/App/Sources/View/Coordinator/Hifi/AppHifiCoordinatorView.swift rename to Projects/App/Sources/Navigation/Hifi/AppHifiCoordinatorView.swift index b68cd4be..45cf672f 100644 --- a/Projects/App/Sources/View/Coordinator/Hifi/AppHifiCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinatorView.swift @@ -6,7 +6,7 @@ import SwiftUI import ComposableArchitecture -import Presentation +import FeatureAssembly import TCAFlow public struct AppHifiCoordinatorView: View { diff --git a/Projects/App/Sources/Reducer/Coordinator/Home/AppHomeCoordinator.swift b/Projects/App/Sources/Navigation/Home/AppHomeCoordinator.swift similarity index 98% rename from Projects/App/Sources/Reducer/Coordinator/Home/AppHomeCoordinator.swift rename to Projects/App/Sources/Navigation/Home/AppHomeCoordinator.swift index d1140e00..0c869511 100644 --- a/Projects/App/Sources/Reducer/Coordinator/Home/AppHomeCoordinator.swift +++ b/Projects/App/Sources/Navigation/Home/AppHomeCoordinator.swift @@ -5,11 +5,11 @@ import Foundation +import AudioPlayerServiceInterface import ComposableArchitecture -import DomainInterface +import PickeCoreUtility import PickeDesignKit -import Presentation -import Shared +import FeatureAssembly import TCAFlow @FlowCoordinator(screen: "AppHomeScreen", navigation: true) diff --git a/Projects/App/Sources/View/Coordinator/Home/AppHomeCoordinatorView.swift b/Projects/App/Sources/Navigation/Home/AppHomeCoordinatorView.swift similarity index 97% rename from Projects/App/Sources/View/Coordinator/Home/AppHomeCoordinatorView.swift rename to Projects/App/Sources/Navigation/Home/AppHomeCoordinatorView.swift index 75c107fa..a81558cd 100644 --- a/Projects/App/Sources/View/Coordinator/Home/AppHomeCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Home/AppHomeCoordinatorView.swift @@ -6,7 +6,7 @@ import SwiftUI import ComposableArchitecture -import Presentation +import FeatureAssembly import TCAFlow public struct AppHomeCoordinatorView: View { diff --git a/Projects/App/Sources/Reducer/Coordinator/MainTab/AppMainTabCoordinator.swift b/Projects/App/Sources/Navigation/MainTab/AppMainTabCoordinator.swift similarity index 98% rename from Projects/App/Sources/Reducer/Coordinator/MainTab/AppMainTabCoordinator.swift rename to Projects/App/Sources/Navigation/MainTab/AppMainTabCoordinator.swift index 0ae24daf..808c52d9 100644 --- a/Projects/App/Sources/Reducer/Coordinator/MainTab/AppMainTabCoordinator.swift +++ b/Projects/App/Sources/Navigation/MainTab/AppMainTabCoordinator.swift @@ -7,9 +7,10 @@ import Foundation +import PickeAnalyticsInterface import ComposableArchitecture import PickeDesignKit -import Presentation +import FeatureAssembly import TCAFlow @Reducer diff --git a/Projects/App/Sources/View/Coordinator/MainTab/AppMainTabView.swift b/Projects/App/Sources/Navigation/MainTab/AppMainTabView.swift similarity index 82% rename from Projects/App/Sources/View/Coordinator/MainTab/AppMainTabView.swift rename to Projects/App/Sources/Navigation/MainTab/AppMainTabView.swift index 584e5e26..b0df67c9 100644 --- a/Projects/App/Sources/View/Coordinator/MainTab/AppMainTabView.swift +++ b/Projects/App/Sources/Navigation/MainTab/AppMainTabView.swift @@ -9,8 +9,9 @@ import SwiftUI import UIKit import ComposableArchitecture +import PickeCoreUI import PickeDesignKit -import Presentation +import FeatureAssembly import TCAFlow public struct AppMainTabView: View { @@ -127,28 +128,38 @@ private extension AppMainTabView { .frame(width: 24, height: 24) } + // TabView 는 네 탭의 콘텐츠를 한꺼번에 만들기 때문에, LazyView 로 감싸 + // 각 탭이 처음 화면에 올라올 때까지 스토어 스코프와 뷰 생성을 미룬다. @ViewBuilder func tabContent(for tab: Int) -> some View { switch AppMainTabCoordinator.Tab(rawValue: tab) { case .home: - AppHomeCoordinatorView( - store: store.scope(state: \.homeState, action: \.home) - ) + LazyView { + AppHomeCoordinatorView( + store: store.scope(state: \.homeState, action: \.home) + ) + } case .explore: - AppHifiCoordinatorView( - store: store.scope(state: \.exploreState, action: \.explore) - ) + LazyView { + AppHifiCoordinatorView( + store: store.scope(state: \.exploreState, action: \.explore) + ) + } case .quickBattle: - AppBattleCoordinatorView( - store: store.scope(state: \.quickBattleState, action: \.quickBattle) - ) + LazyView { + AppBattleCoordinatorView( + store: store.scope(state: \.quickBattleState, action: \.quickBattle) + ) + } case .myPage: - AppProfileCoordinatorView( - store: store.scope(state: \.myPageState, action: \.myPage) - ) + LazyView { + AppProfileCoordinatorView( + store: store.scope(state: \.myPageState, action: \.myPage) + ) + } case .none: EmptyView() diff --git a/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift b/Projects/App/Sources/Navigation/Notification/NotificationCoordinator.swift similarity index 94% rename from Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift rename to Projects/App/Sources/Navigation/Notification/NotificationCoordinator.swift index 93c2b9d4..7079e255 100644 --- a/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift +++ b/Projects/App/Sources/Navigation/Notification/NotificationCoordinator.swift @@ -1,11 +1,12 @@ // // NotificationCoordinator.swift -// Notification +// Picke // import Foundation import ComposableArchitecture +import FeatureAssembly import NotificationInterface import TCAFlow @@ -66,8 +67,8 @@ public struct NotificationCoordinator { } } -extension NotificationCoordinator { - private func routerAction( +private extension NotificationCoordinator { + func routerAction( state _: inout State, action: IndexedRouterActionOf ) -> Effect { @@ -81,7 +82,7 @@ extension NotificationCoordinator { } } - private func handleViewAction( + func handleViewAction( state: inout State, action: View ) -> Effect { @@ -95,7 +96,7 @@ extension NotificationCoordinator { } } - private func handleDelegateAction( + func handleDelegateAction( state _: inout State, action: NotificationDelegate ) -> Effect { diff --git a/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift b/Projects/App/Sources/Navigation/Notification/NotificationCoordinatorView.swift similarity index 93% rename from Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift rename to Projects/App/Sources/Navigation/Notification/NotificationCoordinatorView.swift index 08104f0e..2c466d65 100644 --- a/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Notification/NotificationCoordinatorView.swift @@ -1,13 +1,12 @@ // // NotificationCoordinatorView.swift -// Notification +// Picke // -import Foundation - import SwiftUI import ComposableArchitecture +import FeatureAssembly import TCAFlow public struct NotificationCoordinatorView: View { diff --git a/Projects/App/Sources/Reducer/Coordinator/Profile/AppProfileCoordinator.swift b/Projects/App/Sources/Navigation/Profile/AppProfileCoordinator.swift similarity index 98% rename from Projects/App/Sources/Reducer/Coordinator/Profile/AppProfileCoordinator.swift rename to Projects/App/Sources/Navigation/Profile/AppProfileCoordinator.swift index 7450d3dd..4dafc6e1 100644 --- a/Projects/App/Sources/Reducer/Coordinator/Profile/AppProfileCoordinator.swift +++ b/Projects/App/Sources/Navigation/Profile/AppProfileCoordinator.swift @@ -5,11 +5,10 @@ import Foundation -import AuthDomainInterface +import AudioPlayerServiceInterface import ComposableArchitecture -import DomainInterface -import Entity -import Presentation +import DomainAssembly +import FeatureAssembly import TCAFlow @FlowCoordinator(screen: "AppProfileScreen", navigation: true) diff --git a/Projects/App/Sources/View/Coordinator/Profile/AppProfileCoordinatorView.swift b/Projects/App/Sources/Navigation/Profile/AppProfileCoordinatorView.swift similarity index 98% rename from Projects/App/Sources/View/Coordinator/Profile/AppProfileCoordinatorView.swift rename to Projects/App/Sources/Navigation/Profile/AppProfileCoordinatorView.swift index e75a5607..a085cccf 100644 --- a/Projects/App/Sources/View/Coordinator/Profile/AppProfileCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Profile/AppProfileCoordinatorView.swift @@ -6,7 +6,7 @@ import SwiftUI import ComposableArchitecture -import Presentation +import FeatureAssembly import TCAFlow public struct AppProfileCoordinatorView: View { diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/App/Sources/Navigation/Splash/SplashFeature.swift similarity index 88% rename from Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift rename to Projects/App/Sources/Navigation/Splash/SplashFeature.swift index 2f4d7485..b9c8485f 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/App/Sources/Navigation/Splash/SplashFeature.swift @@ -6,16 +6,15 @@ // import Foundation +import PickeCoreLogger -import DomainInterface -import Entity -import UseCase - +import AppUpdateDomainInterface import ComposableArchitecture -import LogMacro +import PickeAnalyticsInterface +import PickeAuthInterface @Reducer -public struct SplashFeature { +public struct SplashFeature: Sendable { public init() {} @ObservableState @@ -75,7 +74,7 @@ public struct SplashFeature { } @Dependency(\.continuousClock) var clock - @Dependency(\.keychainManager) var keychainManager + @Dependency(\.authService) var authService @Dependency(\.appUpdateUseCase) var appUpdateUseCase @Dependency(\.openURL) var openURL @Dependency(\.analyticsUseCase) var analyticsUseCase @@ -116,7 +115,7 @@ extension SplashFeature { case .onAppear: analyticsUseCase.track(.screenView(screen: .splash, referrer: nil)) analyticsUseCase.track(.onboardingStep(step: .splash, provider: nil)) - return .run { send in + return .run { [clock] send in try await clock.sleep(for: .seconds(1.2)) await send(.async(.checkAppUpdate)) } @@ -156,7 +155,7 @@ extension SplashFeature { return .none case let .failure(error): - Log.error("[Splash] 앱 업데이트 체크 실패: \(error.localizedDescription)") + PickeLogger.error("[Splash] 앱 업데이트 체크 실패: \(error.localizedDescription)", category: .app) return navigateToNextScreen(state: &state) } } @@ -169,7 +168,7 @@ extension SplashFeature { switch action { case .presented(.updateConfirmed): // 지금 업데이트 → App Store 이동 (앱 이탈). - return .run { [appStoreUrl = state.appStoreUrl] _ in + return .run { [appStoreUrl = state.appStoreUrl, openURL] _ in if let url = URL(string: appStoreUrl) { await openURL(url) } @@ -195,9 +194,10 @@ extension SplashFeature { } private func navigateToNextScreen(state _: inout State) -> Effect { - hasStoredCredential - ? .send(.delegate(.presentMainTab)) - : .send(.delegate(.presentAuth)) + let authService = authService + return .run { send in + await send(.delegate(authService.isLoggedIn ? .presentMainTab : .presentAuth)) + } } private static func updateAlert(version: String) -> AlertState { @@ -214,14 +214,4 @@ extension SplashFeature { TextState("새로운 버전 \(version)이 출시되었습니다!\n더 나은 경험을 위해 지금 업데이트하세요!") } } - - private var hasStoredCredential: Bool { - guard - let accessToken = keychainManager.accessToken(), - let refreshToken = keychainManager.refreshToken() - else { - return false - } - return !accessToken.isEmpty && !refreshToken.isEmpty - } } diff --git a/Projects/Presentation/Splash/Sources/View/SplashView.swift b/Projects/App/Sources/Navigation/Splash/SplashView.swift similarity index 70% rename from Projects/Presentation/Splash/Sources/View/SplashView.swift rename to Projects/App/Sources/Navigation/Splash/SplashView.swift index 70673c7d..99448f91 100644 --- a/Projects/Presentation/Splash/Sources/View/SplashView.swift +++ b/Projects/App/Sources/Navigation/Splash/SplashView.swift @@ -8,6 +8,7 @@ import ComposableArchitecture import SwiftUI +import PickeAnimation import PickeDesignKit public struct SplashView: View { @@ -40,10 +41,12 @@ public struct SplashView: View { } } +/// 저장 프로퍼티가 없어 합성된 `==` 가 항상 참이다. +/// `.equatable()` 과 함께 부모가 갱신돼도 GIF 를 다시 그리지 않게 막는다. private struct SplashLogoAnimation: View, Equatable { - static func == (_: Self, _: Self) -> Bool { true } + private static let logoSize = CGSize(width: 250, height: 250) var body: some View { - SplashLogoAnimatedImageView() + PickeAnimatedImageView(.splashLogo, size: Self.logoSize) } } diff --git a/Projects/App/Sources/Push/PushRegistrationService.swift b/Projects/App/Sources/Push/PushRegistrationService.swift new file mode 100644 index 00000000..d0e5285a --- /dev/null +++ b/Projects/App/Sources/Push/PushRegistrationService.swift @@ -0,0 +1,26 @@ +// +// PushRegistrationService.swift +// Picke +// + +import Foundation + +import DeviceServiceInterface +import PickeCoreLogger +import PickeStorageInterface + +enum PushRegistrationService { + static var current: String? { + get { DeviceTokenStorage.token } + set { DeviceTokenStorage.token = newValue } + } + + /// 로그인 직후 / 토큰 갱신 시 디바이스 등록. + @discardableResult + static func register(deviceUseCase: any DeviceInterface = DeviceUseCaseImpl()) async throws -> Bool { + guard let token = current, !token.isEmpty else { return false } + try await deviceUseCase.registerDevice(fcmToken: token, platform: .ios) + PickeLogger.debug("[Push] 디바이스 등록 완료", category: .network) + return true + } +} diff --git a/Projects/App/Sources/Reducer/Root/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift similarity index 75% rename from Projects/App/Sources/Reducer/Root/AppReducer.swift rename to Projects/App/Sources/Reducer/AppReducer.swift index 767ae2bf..4c84a299 100644 --- a/Projects/App/Sources/Reducer/Root/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -5,19 +5,21 @@ // Created by Wonji Suh on 5/6/26. // +import Foundation + import ComposableArchitecture -import Domain -import LogMacro -import NotificationDomainInterface -import Presentation -import Shared +import DomainAssembly +import FeatureAssembly +import PickeAnalyticsInterface +import PickeCoreLogger +import PickeCoreUtility @Reducer public struct AppReducer: Sendable { public init() {} @ObservableState - public enum State { + public enum State: Equatable { case splash(SplashFeature.State) case auth(AppAuthCoordinator.State) case mainTab(AppMainTabCoordinator.State) @@ -42,7 +44,6 @@ public struct AppReducer: Sendable { case view(View) case async(AsyncAction) case inner(InnerAction) - case navigation(NavigationAction) case scope(ScopeAction) } @@ -51,6 +52,8 @@ public struct AppReducer: Sendable { case presentView case presentRoot case presentAuth + /// 앱 시작 전면 팝업 광고 클릭. + case appStartAdClicked } // MARK: - 앱내에서 사용하는 액션 @@ -58,6 +61,7 @@ public struct AppReducer: Sendable { public enum InnerAction: Equatable { case completeAuthTransition case completeMainTabTransition + case pushRegistrationResponse(Result) } // MARK: - 비동기 처리 액션 @@ -67,12 +71,9 @@ public struct AppReducer: Sendable { case refreshTokenExpired case observeDeeplink case deeplinkReceived(PickeDeeplink) + case consumePendingDeeplink } - // MARK: - 네비게이션 연결 액션 - - public enum NavigationAction: Equatable {} - // MARK: - 스코프 액션 @CasePathable @@ -83,6 +84,7 @@ public struct AppReducer: Sendable { } @Dependency(\.continuousClock) var clock + @Dependency(\.analyticsUseCase) private var analyticsUseCase // 🎯 PFW 패턴: 강타입 최소 CancelID (3개로 축소) private enum CancelID: Hashable { @@ -132,13 +134,21 @@ public struct AppReducer: Sendable { case let .async(asyncAction): return handleAsyncAction(state: &state, action: asyncAction) - case let .navigation(navigationAction): - return handleNavigationAction(state: &state, action: navigationAction) - case let .scope(scopeAction): return handleScopeAction(state: &state, action: scopeAction) } } + // ifCaseLet 은 base 를 감싸는 연산자라 배치 순서와 무관하게 자식이 먼저 실행된다. + // 따라서 handleScopeAction 의 상태 일치 검사는 자식이 이미 처리한 뒤에 돈다. + .ifCaseLet(\.splash, action: \.scope.splash) { + SplashFeature() + } + .ifCaseLet(\.auth, action: \.scope.auth) { + AppAuthCoordinator() + } + .ifCaseLet(\.mainTab, action: \.scope.mainTab) { + AppMainTabCoordinator() + } } private func handleViewAction( @@ -159,6 +169,10 @@ public struct AppReducer: Sendable { case .presentAuth: return startTransition(.completeAuthTransition) + + case .appStartAdClicked: + analyticsUseCase.track(.adClick(AdClickData(placement: .appStart, format: .popup))) + return .none } } @@ -181,8 +195,13 @@ public struct AppReducer: Sendable { case .observeDeeplink: return observeDeeplink() + case .consumePendingDeeplink: + guard case .mainTab = state, + let deeplink = AppDeeplinkBridge.consumePending() else { return .none } + return handleAsyncAction(state: &state, action: .deeplinkReceived(deeplink)) + case let .deeplinkReceived(deeplink): - // 메인 진입 상태에서만 즉시 라우팅. 그 외에는 pending(UserDefaults)으로 보류. + // 인앱 요청은 메인에서만 처리한다. 외부 요청은 consumePendingDeeplink에서 보류한다. guard case .mainTab = state else { return .none } switch deeplink { case let .battle(battleId): @@ -209,6 +228,9 @@ public struct AppReducer: Sendable { .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.myPage.rawValue)))), .send(.scope(.mainTab(.myPage(.view(.openTerms))))) ) + case .quickBattle: + // 빠른 배틀은 탭 자체가 목적지라 전환만 한다. + return .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.quickBattle.rawValue)))) } } } @@ -218,6 +240,12 @@ public struct AppReducer: Sendable { action: InnerAction ) -> Effect { switch action { + case let .pushRegistrationResponse(result): + if case let .failure(error) = result { + PickeLogger.error("[Push] 디바이스 등록 실패: \(error.localizedDescription)", category: .network) + } + return .none + case .completeAuthTransition: state = .auth(.init()) return .none @@ -225,30 +253,7 @@ public struct AppReducer: Sendable { case .completeMainTabTransition: state = .mainTab(.init()) // 콜드 스타트/로그인 직후 대기 중이던 딥링크 소비. - if let pending = PushDeeplinkBridge.consumePending() { - return .send(.async(.deeplinkReceived(pending))) - } - return .none - } - } - - private func handleNavigationAction( - state _: inout State, - action _: NavigationAction - ) -> Effect { - return .none - } - - // 🎯 PFW 철학: 단순하고 조합 가능한 상태 검증 - private func isValidAction( - _ action: ScopeAction, - for state: State - ) -> Bool { - switch (action, state) { - case (.auth, .auth), (.splash, .splash), (.mainTab, .mainTab): - return true - default: - return false + return .send(.async(.consumePendingDeeplink)) } } @@ -256,38 +261,10 @@ public struct AppReducer: Sendable { state: inout State, action: ScopeAction ) -> Effect { - guard isValidAction(action, for: state) else { return .none } - - let childEffect = reduceChild(state: &state, action: action) - let navigationEffect = handleScopeNavigation(action: action) - return .merge(childEffect, navigationEffect) - } - - private func reduceChild( - state: inout State, - action: ScopeAction - ) -> Effect { - switch (state, action) { - case var (.splash(childState), .splash(childAction)): - let effect = SplashFeature() - .reduce(into: &childState, action: childAction) - .map { Action.scope(.splash($0)) } - state = .splash(childState) - return effect - - case var (.auth(childState), .auth(childAction)): - let effect = AppAuthCoordinator() - .reduce(into: &childState, action: childAction) - .map { Action.scope(.auth($0)) } - state = .auth(childState) - return effect - - case var (.mainTab(childState), .mainTab(childAction)): - let effect = AppMainTabCoordinator() - .reduce(into: &childState, action: childAction) - .map { Action.scope(.mainTab($0)) } - state = .mainTab(childState) - return effect + // 현재 화면과 다른 Coordinator 의 액션은 조용히 무시한다. + switch (action, state) { + case (.auth, .auth), (.splash, .splash), (.mainTab, .mainTab): + return handleScopeNavigation(action: action) default: return .none @@ -316,7 +293,13 @@ public struct AppReducer: Sendable { // 로그인 성공 → 메인 진입 + APNs 디바이스 토큰 서버 등록. return .merge( .send(.view(.presentRoot)), - .run { _ in await PushTokenStore.register() } + .run { send in + let result = await Result { + try await PushRegistrationService.register() + } + .mapError(AuthError.from) + await send(.inner(.pushRegistrationResponse(result))) + } ) // 로그아웃/탈퇴 → 로그인 화면으로 복귀 @@ -328,18 +311,16 @@ public struct AppReducer: Sendable { } } - private func isSplashState(_ state: State) -> Bool { - guard case .splash = state else { return false } - return true - } - /// 푸시/인앱 알림 탭으로 브로드캐스트된 딥링크를 수신해 라우팅 액션으로 변환. private func observeDeeplink() -> Effect { .run { send in for await notification in NotificationCenter.default.notifications(named: .pickeDeeplink) { - guard let encoded = notification.userInfo?["deeplink"] as? String, - let deeplink = PickeDeeplinkParser.parse(urlString: encoded) else { continue } - await send(.async(.deeplinkReceived(deeplink))) + if notification.userInfo == nil { + await send(.async(.consumePendingDeeplink)) + } else if let encoded = notification.userInfo?["deeplink"] as? String, + let deeplink = PickeDeeplinkParser.parse(urlString: encoded) { + await send(.async(.deeplinkReceived(deeplink))) + } } } .cancellable(id: CancelID.deeplinkListener, cancelInFlight: true) diff --git a/Projects/App/Sources/View/Root/AppView.swift b/Projects/App/Sources/View/AppView.swift similarity index 84% rename from Projects/App/Sources/View/Root/AppView.swift rename to Projects/App/Sources/View/AppView.swift index e4f5e682..f957788e 100644 --- a/Projects/App/Sources/View/Root/AppView.swift +++ b/Projects/App/Sources/View/AppView.swift @@ -7,11 +7,11 @@ import SwiftUI -import AdKit import ComposableArchitecture import PickeDesignKit -import Presentation +import Ad +import FeatureAssembly struct AppView: View { @Bindable var store: StoreOf @@ -47,7 +47,12 @@ struct AppView: View { )) // splash 가 아닌 메인 진입 시점이라 rootViewController 가 준비돼 있다. // 닫기 종류와 관계없이 다음 메인 진입 때 다시 요청한다. - .onAppear { AppStartPopupAd.presentIfNeeded() } + .onAppear { + AppStartPopupAd.presentIfNeeded( + // 이 스코프의 store 는 mainTab 코디네이터라 루트 스토어를 명시한다. + onAdClick: { self.store.send(.view(.appStartAdClicked)) } + ) + } } } } diff --git a/Projects/App/Sources/View/Root/ContentView.swift b/Projects/App/Sources/View/ContentView.swift similarity index 61% rename from Projects/App/Sources/View/Root/ContentView.swift rename to Projects/App/Sources/View/ContentView.swift index b2a85dba..c4eca408 100644 --- a/Projects/App/Sources/View/Root/ContentView.swift +++ b/Projects/App/Sources/View/ContentView.swift @@ -1,16 +1,15 @@ +import FeatureAssembly import SwiftUI -import Presentation public struct ContentView: View { - public init() {} + public init() {} - public var body: some View { - Text("Hello, World!") - .padding() - } + public var body: some View { + Text("Hello, World!") + .padding() + } } - #Preview { ContentView() } @@ -20,4 +19,3 @@ public struct ContentView: View { AppAuthCoordinator() })) } - diff --git a/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift b/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift new file mode 100644 index 00000000..980f9e14 --- /dev/null +++ b/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift @@ -0,0 +1,52 @@ +import ComposableArchitecture +import PickeAnalyticsInterface +import PickeCoreUtility +import PickeStorageInterface +import Testing + +@testable import Picke + +@MainActor +struct AppDeeplinkRoutingTests { + @Test + func 로그인_전에는_보류하고_메인_진입시_한번만_소비한다() async { + await withDependencies { + $0.context = .test + $0.defaultInMemoryStorage = InMemoryStorage() + $0.analyticsUseCase = AnalyticsUseCase( + registerBaseProperties: {}, + identify: { _, _ in }, + track: { _ in }, + reset: {} + ) + } operation: { + @Shared(.pendingDeeplink) var pending: String? + $pending.withLock { $0 = PickeDeeplink.quickBattle.encoded } + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } + store.exhaustivity = .off + + await store.send(.async(.consumePendingDeeplink)) + #expect(pending == PickeDeeplink.quickBattle.encoded) + + await store.send(.inner(.completeMainTabTransition)) + await store.receive { + guard case .async(.consumePendingDeeplink) = $0 else { return false } + return true + } + await store.receive { + guard case let .scope(.mainTab(.selectTab(tab))) = $0 else { return false } + return tab == AppMainTabCoordinator.Tab.quickBattle.rawValue + } + guard case let .mainTab(state) = store.state else { + Issue.record("메인 화면으로 전환되지 않음") + return + } + #expect(state.selectedTab == AppMainTabCoordinator.Tab.quickBattle.rawValue) + #expect(pending == nil) + await store.send(.async(.consumePendingDeeplink)) + await store.finish() + } + } +} diff --git a/Projects/App/Tests/Sources/MultiModuleTemplateTests.swift b/Projects/App/Tests/Sources/MultiModuleTemplateTests.swift index 393bad25..3ee03c36 100644 --- a/Projects/App/Tests/Sources/MultiModuleTemplateTests.swift +++ b/Projects/App/Tests/Sources/MultiModuleTemplateTests.swift @@ -2,7 +2,7 @@ import Foundation import XCTest final class MultiModuleTemplateTests: XCTestCase { - func test_twoPlusTwo_isFour() { - XCTAssertEqual(2+2, 4) - } -} \ No newline at end of file + func test_twoPlusTwo_isFour() { + XCTAssertEqual(2 + 2, 4) + } +} diff --git a/Projects/App/Tests/Sources/NotificationCoordinatorTests.swift b/Projects/App/Tests/Sources/NotificationCoordinatorTests.swift new file mode 100644 index 00000000..5e4cc3af --- /dev/null +++ b/Projects/App/Tests/Sources/NotificationCoordinatorTests.swift @@ -0,0 +1,41 @@ +import ComposableArchitecture +import TCAFlow +import Testing + +@testable import Picke + +@MainActor +struct NotificationCoordinatorTests { + @Test + func route초기화는_알림_목록을_루트로_연다() { + let state = NotificationCoordinator.State(route: .inbox) + + #expect(state.routes.count == 1) + guard let route = state.routes.first else { + Issue.record("알림 루트 route가 없음") + return + } + #expect(route.embedInNavigationView) + guard case .notification = route.screen else { + Issue.record("알림 목록 화면으로 시작하지 않음") + return + } + } + + @Test + func 알림_목록_dismiss는_부모_delegate로_전달된다() async { + let store = TestStore(initialState: NotificationCoordinator.State(route: .inbox)) { + NotificationCoordinator() + } + + await store.send(.router(.routeAction( + id: 0, + action: .notification(.delegate(.dismiss)) + ))) + await store.receive { + guard case .delegate(.dismiss) = $0 else { return false } + return true + } + await store.finish() + } +} diff --git a/Projects/App/Tests/Sources/PendingDeeplinkStoreTests.swift b/Projects/App/Tests/Sources/PendingDeeplinkStoreTests.swift new file mode 100644 index 00000000..d189212f --- /dev/null +++ b/Projects/App/Tests/Sources/PendingDeeplinkStoreTests.swift @@ -0,0 +1,46 @@ +import ComposableArchitecture +import PickeCoreUtility +import PickeStorageInterface +import Testing + +@testable import Picke + +struct PendingDeeplinkStoreTests { + @Test + func 대기_링크는_한번만_소비된다() { + withDependencies { + $0.context = .test + } operation: { + let store = PendingDeeplinkStore() + store.save(.quickBattle) + #expect(store.consume() == .quickBattle) + #expect(store.consume() == nil) + } + } + + @Test + func 저장소_사이에_최신_대기_링크를_공유한다() { + withDependencies { + $0.context = .test + } operation: { + let first = PendingDeeplinkStore() + let second = PendingDeeplinkStore() + first.save(.battle(battleId: 1)) + second.save(.quickBattle) + #expect(first.consume() == .quickBattle) + #expect(second.consume() == nil) + } + } + + @Test + func 잘못된_링크도_소비하면_제거한다() { + withDependencies { + $0.context = .test + } operation: { + @Shared(.pendingDeeplink) var encoded: String? + $encoded.withLock { $0 = "invalid" } + #expect(PendingDeeplinkStore().consume() == nil) + #expect(encoded == nil) + } + } +} diff --git a/Projects/App/Tests/Sources/PickeTests.swift b/Projects/App/Tests/Sources/PickeTests.swift index b75ae217..d1318e13 100644 --- a/Projects/App/Tests/Sources/PickeTests.swift +++ b/Projects/App/Tests/Sources/PickeTests.swift @@ -8,24 +8,21 @@ import XCTest final class PickeTests: XCTestCase { + override func setUpWithError() throws { + } - override func setUpWithError() throws { - // Put setup code here. - } + override func tearDownWithError() throws { + // Put teardown code here. + } - override func tearDownWithError() throws { - // Put teardown code here. - } + func testExample() throws { + // This is an example of a functional test case. + } - func testExample() throws { - // This is an example of a functional test case. + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} \ No newline at end of file + } +} diff --git a/Projects/App/Tests/Sources/PushRegistrationServiceTests.swift b/Projects/App/Tests/Sources/PushRegistrationServiceTests.swift new file mode 100644 index 00000000..bdcc2e53 --- /dev/null +++ b/Projects/App/Tests/Sources/PushRegistrationServiceTests.swift @@ -0,0 +1,88 @@ +// +// PushRegistrationServiceTests.swift +// PickeTests +// + +import ComposableArchitecture +import DeviceServiceInterface +import PickeStorageInterface +import Testing + +@testable import Picke + +@Suite("PushRegistrationService", .serialized) +struct PushRegistrationServiceTests { + @Test + func 토큰이_없으면_등록하지_않고_false를_반환한다() async throws { + try await withDependencies { + $0.context = .test + } operation: { + DeviceTokenStorage.token = nil + let useCase = FakeDeviceUseCase() + + let registered = try await PushRegistrationService.register(deviceUseCase: useCase) + + #expect(registered == false) + #expect(await useCase.registeredTokens.isEmpty) + } + } + + @Test + func 토큰이_있으면_ios_플랫폼으로_등록하고_true를_반환한다() async throws { + try await withDependencies { + $0.context = .test + } operation: { + DeviceTokenStorage.token = "apns-token" + defer { DeviceTokenStorage.token = nil } + let useCase = FakeDeviceUseCase() + + let registered = try await PushRegistrationService.register(deviceUseCase: useCase) + + #expect(registered == true) + #expect(await useCase.registeredTokens == ["apns-token"]) + #expect(await useCase.registeredPlatforms == [.ios]) + } + } + + @Test + func 등록_실패는_호출자에게_throw된다() async { + await withDependencies { + $0.context = .test + } operation: { + DeviceTokenStorage.token = "apns-token" + defer { DeviceTokenStorage.token = nil } + let useCase = FakeDeviceUseCase(registerError: RegistrationError.failed) + + await #expect(throws: RegistrationError.failed) { + try await PushRegistrationService.register(deviceUseCase: useCase) + } + } + } +} + +private actor FakeDeviceUseCase: DeviceInterface { + private(set) var registeredTokens: [String] = [] + private(set) var registeredPlatforms: [DevicePlatform] = [] + private let registerError: RegistrationError? + + init(registerError: RegistrationError? = nil) { + self.registerError = registerError + } + + func registerDevice( + fcmToken: String, + platform: DevicePlatform + ) async throws { + if let registerError { + throw registerError + } + registeredTokens.append(fcmToken) + registeredPlatforms.append(platform) + } + + func unregisterDevice(fcmToken _: String) async throws {} +} + +private enum RegistrationError: Error, Equatable { + case failed +} diff --git a/Projects/Presentation/Splash/Tests/Sources/SplashTests.swift b/Projects/App/Tests/Sources/SplashTests.swift similarity index 71% rename from Projects/Presentation/Splash/Tests/Sources/SplashTests.swift rename to Projects/App/Tests/Sources/SplashTests.swift index 28d7fc70..ad9c4773 100644 --- a/Projects/Presentation/Splash/Tests/Sources/SplashTests.swift +++ b/Projects/App/Tests/Sources/SplashTests.swift @@ -1,6 +1,6 @@ // // SplashTests.swift -// Presentation.SplashTests +// Feature.SplashTests // // Created by Roy on 2026-05-02. // @@ -8,11 +8,11 @@ import ComposableArchitecture import Testing -import DomainInterface -import Entity -import UseCase +import AppUpdateDomainInterface +import PickeAnalyticsInterface +import PickeAuthInterface -@testable import Splash +@testable import Picke /// 앱 업데이트 없음(nil)을 반환하는 테스트 스텁. private struct StubAppUpdateUseCase: AppUpdateUseCaseInterface { @@ -20,6 +20,21 @@ private struct StubAppUpdateUseCase: AppUpdateUseCaseInterface { func checkForUpdate() async throws -> AppUpdateInfo? { info } } +/// 저장된 토큰 유무만 흉내내는 인증 서비스 스텁. +private actor StubAuthService: AuthService { + private var loggedIn: Bool + + init(loggedIn: Bool) { + self.loggedIn = loggedIn + } + + var isLoggedIn: Bool { loggedIn } + var refreshToken: String? { loggedIn ? "refresh-token" : nil } + + func signIn(accessToken _: String, refreshToken _: String) { loggedIn = true } + func signOut() { loggedIn = false } +} + /// Mixpanel 을 건드리지 않는 no-op 분석 UseCase. private let noopAnalytics = AnalyticsUseCase( registerBaseProperties: {}, @@ -28,20 +43,17 @@ private let noopAnalytics = AnalyticsUseCase( reset: {} ) +@MainActor struct SplashTests { @Test func onAppearRoutesToMainTabWhenTokensExist() async { - let keychainManager = InMemoryKeychainManager() - keychainManager.save(accessToken: "access-token", refreshToken: "refresh-token") let clock = TestClock() let store = TestStore(initialState: SplashFeature.State()) { SplashFeature() } withDependencies: { $0.continuousClock = clock - // keychainManager 접근자가 DomainInterface/UseCase 양쪽에 중복 정의되어 모호하므로, - // SplashFeature 가 실제로 읽는 UseCase 의 키를 모듈 한정 subscript 로 오버라이드한다. - $0.keychainManager = keychainManager + $0.authService = StubAuthService(loggedIn: true) $0.appUpdateUseCase = StubAppUpdateUseCase(info: nil) $0.analyticsUseCase = noopAnalytics } @@ -61,7 +73,7 @@ struct SplashTests { SplashFeature() } withDependencies: { $0.continuousClock = clock - $0.keychainManager = InMemoryKeychainManager() + $0.authService = StubAuthService(loggedIn: false) $0.appUpdateUseCase = StubAppUpdateUseCase(info: nil) $0.analyticsUseCase = noopAnalytics } diff --git a/Projects/Core/CoreAssembly/Project.swift b/Projects/Core/CoreAssembly/Project.swift new file mode 100644 index 00000000..718a82e8 --- /dev/null +++ b/Projects/Core/CoreAssembly/Project.swift @@ -0,0 +1,24 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "CoreAssembly", + bundleId: .appBundleID(name: ".CoreAssembly"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.thirdParty), + .core(.logger), + .core(.network), + .core(.coreUtility), + .core(.coreUI), + .core(.storage, .implementation), + .SPM.composableArchitecture, + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Core/CoreAssembly/Sources/Exported/CoreAssemblyExported.swift b/Projects/Core/CoreAssembly/Sources/Exported/CoreAssemblyExported.swift new file mode 100644 index 00000000..a7947e26 --- /dev/null +++ b/Projects/Core/CoreAssembly/Sources/Exported/CoreAssemblyExported.swift @@ -0,0 +1,13 @@ +// +// CoreAssemblyExported.swift +// CoreAssembly +// + +// MARK: - Core 레이어 한번에 노출 + +@_exported import PickeThirdParty +@_exported import PickeCoreLogger +@_exported import PickeNetwork +@_exported import PickeCoreUtility +@_exported import PickeCoreUI +@_exported import PickeStorage diff --git a/Projects/Core/CoreAssembly/Sources/NetworkAssembly.swift b/Projects/Core/CoreAssembly/Sources/NetworkAssembly.swift new file mode 100644 index 00000000..451c1195 --- /dev/null +++ b/Projects/Core/CoreAssembly/Sources/NetworkAssembly.swift @@ -0,0 +1,13 @@ +// +// NetworkAssembly.swift +// CoreAssembly +// + +import PickeNetwork +import PickeNetworkInterface + +public enum NetworkAssembly { + public static func plainClient() -> any PickeNetworkClient { + NetworkClientFactory.plain() + } +} diff --git a/Projects/Core/CoreAssembly/Sources/StorageAssembly.swift b/Projects/Core/CoreAssembly/Sources/StorageAssembly.swift new file mode 100644 index 00000000..78cae469 --- /dev/null +++ b/Projects/Core/CoreAssembly/Sources/StorageAssembly.swift @@ -0,0 +1,24 @@ +// +// StorageAssembly.swift +// CoreAssembly +// + +import PickeStorage +import PickeStorageInterface + +import Dependencies + +public enum StorageAssembly { + /// 앱 전역이 공유하는 Keychain 기반 보안 저장소를 내놓는다. + public static func secureStorage() -> any SecureStorage { + StorageFactory.secureStorage + } + + public static func sharedValueStorage() -> any SharedValueStorage { + StorageFactory.sharedValueStorage + } + + public static func register(into values: inout DependencyValues) { + StorageFactory.register(into: &values) + } +} diff --git a/Projects/Core/CoreAssembly/Tests/Sources/StorageAssemblyTests.swift b/Projects/Core/CoreAssembly/Tests/Sources/StorageAssemblyTests.swift new file mode 100644 index 00000000..d7133090 --- /dev/null +++ b/Projects/Core/CoreAssembly/Tests/Sources/StorageAssemblyTests.swift @@ -0,0 +1,36 @@ +// +// StorageAssemblyTests.swift +// CoreAssemblyTests +// + +import Foundation +import Testing + +@testable import CoreAssembly +@testable import PickeStorage +import PickeStorageInterface + +import Dependencies + +struct StorageAssemblyTests { + /// 등록을 빼먹으면 공유 값 경로가 Unimplemented 기본값에 걸려 전부 throw 한다. + /// 실제 읽고 쓰는 데까지 가봐야 그 누락이 드러난다. + @Test + func register_후에는_공유값을_실제로_읽고_쓴다() throws { + var values = DependencyValues() + StorageAssembly.register(into: &values) + let storage = values.sharedValueStorage + let key = "StorageAssemblyTests-\(UUID().uuidString)" + let payload = Data("값".utf8) + + try storage.save(payload, forKey: key) + defer { try? storage.remove(forKey: key) } + + #expect(try storage.load(forKey: key) == payload) + } + + @Test + func secureStorage_는_Keychain_구현을_내놓는다() { + #expect(StorageAssembly.secureStorage() is KeychainStorage) + } +} diff --git a/Projects/Core/PickeCoreLogger/Project.swift b/Projects/Core/PickeCoreLogger/Project.swift new file mode 100644 index 00000000..ff138527 --- /dev/null +++ b/Projects/Core/PickeCoreLogger/Project.swift @@ -0,0 +1,17 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeCoreLogger", + bundleId: .appBundleID(name: ".PickeCoreLogger"), + product: .framework, + settings: .settings(), + dependencies: [ + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Core/PickeCoreLogger/Sources/PickeLogCategory.swift b/Projects/Core/PickeCoreLogger/Sources/PickeLogCategory.swift new file mode 100644 index 00000000..b96f4cc3 --- /dev/null +++ b/Projects/Core/PickeCoreLogger/Sources/PickeLogCategory.swift @@ -0,0 +1,26 @@ +// +// PickeLogCategory.swift +// PickeCoreLogger +// +// Copyright © 2026 Picke. All rights reserved. +// + +import Foundation + +/// 로그 카테고리. os.Logger 의 category 로 쓰인다. +public enum PickeLogCategory: String, Sendable, CaseIterable { + /// 앱 전반 / 생명주기 (실행, 초기화, 어디에도 안 맞는 일반 로그) + case app + /// 네트워크 통신 (요청/응답/에러) + case network + /// 인증 (로그인, 토큰 갱신/만료) + case auth + /// 화면 전환 / 라우팅 + case navigation + /// 로컬 저장소 (키체인, UserDefaults, 캐시 등) + case storage + /// UI / 사용자 인터랙션 + case ui + /// 배틀 도메인 (투표, 관점, 댓글) + case battle +} diff --git a/Projects/Core/PickeCoreLogger/Sources/PickeLogLevel.swift b/Projects/Core/PickeCoreLogger/Sources/PickeLogLevel.swift new file mode 100644 index 00000000..966a4fae --- /dev/null +++ b/Projects/Core/PickeCoreLogger/Sources/PickeLogLevel.swift @@ -0,0 +1,46 @@ +// +// PickeLogLevel.swift +// PickeCoreLogger +// +// Copyright © 2026 Picke. All rights reserved. +// + +import Foundation +import OSLog + +enum PickeLogLevel: Int, Comparable, CaseIterable { + /// 개발 중 상세 추적용. 릴리즈엔 남지 않음(휘발). + case debug + /// 참고 정보. 릴리즈 디스크엔 저장되지 않음(메모리만). + case info + /// 기본 레벨. 릴리즈에서도 persist (눈여겨볼 일반 이벤트). + case notice + /// 에러 — 예상치 못한 실패. 릴리즈 persist. + case error + /// 심각한 결함 — 앱 동작을 위협하는 치명적 상황. 릴리즈 persist. + case fault + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var osLogType: OSLogType { + switch self { + case .debug: .debug + case .info: .info + case .notice: .default + case .error: .error + case .fault: .fault + } + } + + var emoji: String { + switch self { + case .debug: "⚪️" + case .info: "🔵" + case .notice: "🟢" + case .error: "🟡" + case .fault: "🔴" + } + } +} diff --git a/Projects/Core/PickeCoreLogger/Sources/PickeLogger.swift b/Projects/Core/PickeCoreLogger/Sources/PickeLogger.swift new file mode 100644 index 00000000..f44a82c6 --- /dev/null +++ b/Projects/Core/PickeCoreLogger/Sources/PickeLogger.swift @@ -0,0 +1,98 @@ +// +// PickeLogger.swift +// PickeCoreLogger +// +// Copyright © 2026 Picke. All rights reserved. +// + +import Foundation +import OSLog + +/// os.Logger 기반 전역 로거 (정적 네임스페이스). +/// 값은 항상 `.private` 로 기록 — 릴리즈 통합로그/sysdiagnose 에 평문 유출을 막는다. +/// (개발 중 디버거가 붙은 상태에서는 값이 그대로 보인다) +public enum PickeLogger { + private static let subsystem: String = Bundle.main.bundleIdentifier ?? "com.picke.picke" + /// 이 레벨 미만은 출력 스킵. 릴리즈에선 notice 이상만. + private static let minimumLevel: PickeLogLevel = PickeLogger.defaultMinimumLevel + + public static func debug( + _ message: String, + category: PickeLogCategory, + fileID: String = #fileID, + function: String = #function, + line: Int = #line + ) { + emit(.debug, category, message, fileID, function, line) + } + + public static func info( + _ message: String, + category: PickeLogCategory, + fileID: String = #fileID, + function: String = #function, + line: Int = #line + ) { + emit(.info, category, message, fileID, function, line) + } + + public static func notice( + _ message: String, + category: PickeLogCategory, + fileID: String = #fileID, + function: String = #function, + line: Int = #line + ) { + emit(.notice, category, message, fileID, function, line) + } + + public static func error( + _ message: String, + category: PickeLogCategory, + fileID: String = #fileID, + function: String = #function, + line: Int = #line + ) { + emit(.error, category, message, fileID, function, line) + } + + public static func fault( + _ message: String, + category: PickeLogCategory, + fileID: String = #fileID, + function: String = #function, + line: Int = #line + ) { + emit(.fault, category, message, fileID, function, line) + } + + /// 코어 — minimumLevel 통과 시에만 출력. 호출 위치(fileID/function/line)를 머리줄에 붙인다. + private static func emit( + _ level: PickeLogLevel, + _ category: PickeLogCategory, + _ message: String, + _ fileID: String, + _ function: String, + _ line: Int + ) { + guard level >= minimumLevel else { return } + + let logger = Logger(subsystem: subsystem, category: category.rawValue) + // #fileID 는 "모듈/파일.swift" — 파일명만 떼서 짧게 (절대경로 노출 X). + let file = fileID.split(separator: "/").last.map(String.init) ?? fileID + // 메타(이모지/카테고리/위치)는 public 으로 항상 보이게, message 값만 private 로 가린다. + logger.log(level: level.osLogType, """ + \(level.emoji, privacy: .public) [\(category.rawValue, privacy: .public)] \(message, privacy: .private) + ↳ \(file, privacy: .public):\(line, privacy: .public) \(function, privacy: .public) + """) + } + + /// DEBUG 는 debug 부터, 릴리즈는 notice 부터. + static var defaultMinimumLevel: PickeLogLevel { + #if DEBUG + .debug + #else + .notice + #endif + } +} diff --git a/Projects/Core/PickeCoreLogger/Tests/Sources/PickeCoreLoggerTests.swift b/Projects/Core/PickeCoreLogger/Tests/Sources/PickeCoreLoggerTests.swift new file mode 100644 index 00000000..76ec6a5d --- /dev/null +++ b/Projects/Core/PickeCoreLogger/Tests/Sources/PickeCoreLoggerTests.swift @@ -0,0 +1,69 @@ +// +// PickeCoreLoggerTests.swift +// PickeCoreLogger +// +// Copyright © 2026 Picke. All rights reserved. +// + +import OSLog +import Testing + +@testable import PickeCoreLogger + +@Suite("PickeCoreLogger") +struct PickeCoreLoggerTests { + @Test("카테고리는 OSLog category 문자열 계약을 유지한다") + func categoriesExposeExpectedRawValues() { + let rawValues = PickeLogCategory.allCases.map(\.rawValue) + + #expect(rawValues == [ + "app", + "network", + "auth", + "navigation", + "storage", + "ui", + "battle" + ]) + } + + @Test("로그 레벨은 심각도 순서대로 정렬된다") + func logLevelsSortBySeverity() { + #expect(PickeLogLevel.allCases.sorted() == [ + .debug, + .info, + .notice, + .error, + .fault + ]) + } + + @Test("로그 레벨은 OSLogType으로 매핑된다") + func logLevelsMapToOSLogTypes() { + #expect(PickeLogLevel.debug.osLogType == .debug) + #expect(PickeLogLevel.info.osLogType == .info) + #expect(PickeLogLevel.notice.osLogType == .default) + #expect(PickeLogLevel.error.osLogType == .error) + #expect(PickeLogLevel.fault.osLogType == .fault) + } + + @Test("DEBUG 빌드의 기본 최소 레벨은 debug다") + func debugBuildMinimumLevelStartsAtDebug() { + #if DEBUG + #expect(PickeLogger.defaultMinimumLevel == .debug) + #else + #expect(PickeLogger.defaultMinimumLevel == .notice) + #endif + } + + @Test("공개 로그 API는 파일 위치 메타데이터와 함께 호출할 수 있다") + func publicLogAPIsAcceptExplicitCallsiteMetadata() { + PickeLogger.debug("debug", category: .app, fileID: "Module/File.swift", function: "test()", line: 10) + PickeLogger.info("info", category: .network, fileID: "Module/File.swift", function: "test()", line: 11) + PickeLogger.notice("notice", category: .auth, fileID: "Module/File.swift", function: "test()", line: 12) + PickeLogger.error("error", category: .storage, fileID: "Module/File.swift", function: "test()", line: 13) + PickeLogger.fault("fault", category: .battle, fileID: "Module/File.swift", function: "test()", line: 14) + + #expect(Bool(true)) + } +} diff --git a/Projects/Core/PickeCoreUI/Project.swift b/Projects/Core/PickeCoreUI/Project.swift new file mode 100644 index 00000000..ecd12948 --- /dev/null +++ b/Projects/Core/PickeCoreUI/Project.swift @@ -0,0 +1,19 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +// 디자인 토큰을 모르는 순수 UIKit/SwiftUI 확장만 둔다. +// 토큰이나 리소스 번들을 참조하는 순간 PickeDesignKit 소속이다. +let project = Project.makeModule( + name: "PickeCoreUI", + bundleId: .appBundleID(name: ".PickeCoreUI"), + product: .framework, + settings: .settings(), + dependencies: [ + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/Color/Color+.swift b/Projects/Core/PickeCoreUI/Sources/Color/Color+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/Color/Color+.swift rename to Projects/Core/PickeCoreUI/Sources/Color/Color+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/Color/UIColor+.swift b/Projects/Core/PickeCoreUI/Sources/Color/UIColor+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/Color/UIColor+.swift rename to Projects/Core/PickeCoreUI/Sources/Color/UIColor+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/Image/Image+.swift b/Projects/Core/PickeCoreUI/Sources/Image/Image+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/Image/Image+.swift rename to Projects/Core/PickeCoreUI/Sources/Image/Image+.swift diff --git a/Projects/Core/PickeCoreUI/Sources/Lazy/LazyView.swift b/Projects/Core/PickeCoreUI/Sources/Lazy/LazyView.swift new file mode 100644 index 00000000..93f8121d --- /dev/null +++ b/Projects/Core/PickeCoreUI/Sources/Lazy/LazyView.swift @@ -0,0 +1,33 @@ +// +// LazyView.swift +// PickeCoreUI +// + +import SwiftUI + +/// 자식 뷰의 생성을 실제로 그려질 때까지 미루는 래퍼. +/// +/// `TabView` 는 `ForEach` 로 모든 탭의 콘텐츠를 한 번에 만들기 때문에, +/// 선택되지 않은 탭의 뷰와 그 뷰가 붙잡는 값(스토어 스코프 등)까지 즉시 생성된다. +/// 이 래퍼를 씌우면 `body` 가 평가되는 시점 — 즉 해당 탭이 처음 화면에 올라올 때 — +/// 까지 생성이 지연된다. +/// +/// ```swift +/// LazyView { +/// HomeCoordinatorView(store: store.scope(state: \.homeState, action: \.home)) +/// } +/// ``` +/// +/// SwiftUIX 의 `LazyView` 와 같은 구현이다. 라이브러리 전체를 의존성으로 +/// 들이는 대신 필요한 이 한 조각만 옮겨 왔다. (SwiftUIX, MIT License) +public struct LazyView: View { + private let content: () -> Content + + public init(@ViewBuilder _ content: @escaping () -> Content) { + self.content = content + } + + public var body: Content { + content() + } +} diff --git a/Projects/Core/PickeCoreUI/Sources/Lifecycle/View+onFirstAppear.swift b/Projects/Core/PickeCoreUI/Sources/Lifecycle/View+onFirstAppear.swift new file mode 100644 index 00000000..974b084c --- /dev/null +++ b/Projects/Core/PickeCoreUI/Sources/Lifecycle/View+onFirstAppear.swift @@ -0,0 +1,38 @@ +// +// View+onFirstAppear.swift +// PickeCoreUI +// + +import SwiftUI + +public extension View { + /// 뷰가 **처음** 화면에 올라올 때 한 번만 실행한다. + /// + /// `onAppear` 는 탭 전환·네비게이션 복귀·리스트 셀 재사용마다 다시 불린다. + /// 초기 로드처럼 한 번이면 충분한 작업은 매 호출마다 `if !didLoad` 가드를 + /// 손으로 다는 대신 이 모디파이어를 쓴다. + /// + /// ```swift + /// .onFirstAppear { send(.onAppear) } + /// ``` + /// + /// SwiftUIX 의 `onAppearOnce` 와 같은 구현이다. 라이브러리 전체를 의존성으로 + /// 들이는 대신 필요한 한 조각만 옮겨 왔다. (SwiftUIX, MIT License) + func onFirstAppear(_ action: @escaping () -> Void) -> some View { + modifier(OnFirstAppearModifier(action: action)) + } +} + +private struct OnFirstAppearModifier: ViewModifier { + let action: () -> Void + + @State private var didAppear = false + + func body(content: Content) -> some View { + content.onAppear { + guard !didAppear else { return } + didAppear = true + action() + } + } +} diff --git a/Projects/Core/PickeCoreUI/Sources/Measure/View+measureSize.swift b/Projects/Core/PickeCoreUI/Sources/Measure/View+measureSize.swift new file mode 100644 index 00000000..6a49537c --- /dev/null +++ b/Projects/Core/PickeCoreUI/Sources/Measure/View+measureSize.swift @@ -0,0 +1,35 @@ +// +// View+measureSize.swift +// PickeCoreUI +// + +import SwiftUI + +public extension View { + /// 뷰가 실제로 그려진 크기를 알려준다. + /// + /// 레이아웃에는 영향을 주지 않는다 — 배경에 깔린 `GeometryReader` 가 크기만 + /// 읽어 `PreferenceKey` 로 올려보낸다. 크기가 바뀔 때마다 다시 불린다. + /// + /// ```swift + /// Text(title) + /// .measureSize { titleSize = $0 } + /// ``` + /// + /// SwiftUIX 의 `measureSize(_:)` 와 같은 구현이다. 라이브러리 전체를 의존성으로 + /// 들이는 대신 필요한 한 조각만 옮겨 왔다. (SwiftUIX, MIT License) + func measureSize(_ onChange: @escaping (CGSize) -> Void) -> some View { + background( + GeometryReader { proxy in + Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size) + } + ) + .onPreferenceChange(SizePreferenceKey.self, perform: onChange) + } +} + +private struct SizePreferenceKey: PreferenceKey { + static let defaultValue = CGSize.zero + + static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) {} +} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Navigaion/UINavigationController+gesture.swift b/Projects/Core/PickeCoreUI/Sources/Navigation/UINavigationController+gesture.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Navigaion/UINavigationController+gesture.swift rename to Projects/Core/PickeCoreUI/Sources/Navigation/UINavigationController+gesture.swift diff --git a/Projects/Core/PickeCoreUI/Sources/ScreenSize/UIScreen+.swift b/Projects/Core/PickeCoreUI/Sources/ScreenSize/UIScreen+.swift new file mode 100644 index 00000000..828d1c83 --- /dev/null +++ b/Projects/Core/PickeCoreUI/Sources/ScreenSize/UIScreen+.swift @@ -0,0 +1,16 @@ +// +// UIScreen+.swift +// PickeCoreUI +// + +import SwiftUI + +public extension UIScreen { + /// 현재 화면 크기. 회전·멀티태스킹으로 값이 바뀌므로 매번 다시 읽는다. + /// + /// SwiftUIX 의 `Screen.main.bounds` 와 같이 접근 시점에 계산한다. + /// (`static let` 으로 캐싱하면 앱 시작 시점의 값에 고정된다.) + static var screenSize: CGSize { UIScreen.main.bounds.size } + static var screenWidth: CGFloat { screenSize.width } + static var screenHeight: CGFloat { screenSize.height } +} diff --git a/Projects/Core/PickeCoreUI/Tests/Sources/LazyViewTests.swift b/Projects/Core/PickeCoreUI/Tests/Sources/LazyViewTests.swift new file mode 100644 index 00000000..7ed8ff9c --- /dev/null +++ b/Projects/Core/PickeCoreUI/Tests/Sources/LazyViewTests.swift @@ -0,0 +1,41 @@ +// +// LazyViewTests.swift +// PickeCoreUITests +// + +import SwiftUI +import Testing + +@testable import PickeCoreUI + +@MainActor +struct LazyViewTests { + /// TabView 가 모든 탭 콘텐츠를 즉시 만들지 않도록 막는 것이 이 래퍼의 존재 이유다. + @Test + func 생성_시점에는_자식_뷰를_만들지_않는다() { + let builder = CountingViewBuilder() + _ = LazyView { builder.make() } + + #expect(builder.count == 0) + } + + @Test + func body_를_평가할_때_자식_뷰를_만든다() { + let builder = CountingViewBuilder() + let sut = LazyView { builder.make() } + + _ = sut.body + + #expect(builder.count == 1) + } +} + +@MainActor +private final class CountingViewBuilder { + private(set) var count = 0 + + func make() -> Text { + count += 1 + return Text("content") + } +} diff --git a/Projects/Core/PickeCoreUI/Tests/Sources/ScreenSizeTests.swift b/Projects/Core/PickeCoreUI/Tests/Sources/ScreenSizeTests.swift new file mode 100644 index 00000000..5db48e11 --- /dev/null +++ b/Projects/Core/PickeCoreUI/Tests/Sources/ScreenSizeTests.swift @@ -0,0 +1,20 @@ +// +// ScreenSizeTests.swift +// PickeCoreUITests +// + +import UIKit +import Testing + +@testable import PickeCoreUI + +@MainActor +struct ScreenSizeTests { + /// `static let` 으로 캐싱하면 앱 시작 시점 값에 고정되므로, 접근할 때마다 다시 읽어야 한다. + @Test + func 화면_크기는_접근_시점의_bounds_를_반영한다() { + #expect(UIScreen.screenSize == UIScreen.main.bounds.size) + #expect(UIScreen.screenWidth == UIScreen.main.bounds.width) + #expect(UIScreen.screenHeight == UIScreen.main.bounds.height) + } +} diff --git a/Projects/Domain/DomainTesting/Project.swift b/Projects/Core/PickeCoreUtility/Project.swift similarity index 51% rename from Projects/Domain/DomainTesting/Project.swift rename to Projects/Core/PickeCoreUtility/Project.swift index cd202da9..109e18d9 100644 --- a/Projects/Domain/DomainTesting/Project.swift +++ b/Projects/Core/PickeCoreUtility/Project.swift @@ -1,16 +1,19 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .module(name: "DomainTesting"), - bundleId: .appBundleID(name: ".DomainTesting"), +import ProjectDescription + +let project = Project.makeModule( + name: "PickeCoreUtility", + bundleId: .appBundleID(name: ".PickeCoreUtility"), product: .framework, settings: .settings(), dependencies: [ - .Domain(implements: .UseCase), + .SPM.dependencies, + .core(.network), ], - sources: ["Sources/**"] + hasTests: true ) diff --git a/Projects/Core/PickeCoreUtility/Sources/Date/AppDateFormat.swift b/Projects/Core/PickeCoreUtility/Sources/Date/AppDateFormat.swift new file mode 100644 index 00000000..2918a208 --- /dev/null +++ b/Projects/Core/PickeCoreUtility/Sources/Date/AppDateFormat.swift @@ -0,0 +1,113 @@ +// +// AppDateFormat.swift +// PickeCoreUtility +// + +import Foundation + +public enum AppDateFormat: String, CaseIterable, Sendable { + case dateTime = "yyyy-MM-dd HH:mm:ss" + case yearMonthDay = "yyyy-MM-dd" + case fullKoreanDate = "yyyy년 MM월 dd일" + case yearMonthDayDotted = "yyyy.MM.dd" + /// 0 을 채우지 않는 점 표기 (예: 2026.4.10) + case yearMonthDayDotShort = "yyyy.M.d" + /// 타임존 없이 내려오는 서버 시각 (LocalDateTime) + case serverDateTime = "yyyy-MM-dd'T'HH:mm:ss" + case serverDateTimeMillis = "yyyy-MM-dd'T'HH:mm:ss.SSS" + case serverDateTimeMicros = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" +} + +enum AppDateFormatter { + private static let locale = Locale(identifier: "en_US_POSIX") + private static let timeZone = TimeZone(identifier: "Asia/Seoul") ?? .gmt + private static let calendar = Calendar(identifier: .gregorian) + + /// 서버 날짜 문자열 파싱 전용 + static let parser: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.calendar = calendar + formatter.dateFormat = AppDateFormat.dateTime.rawValue + return formatter + }() + + /// 화면 출력 전용 + private static let formatters: [AppDateFormat: DateFormatter] = Dictionary( + uniqueKeysWithValues: AppDateFormat.allCases.map { format in + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.calendar = calendar + formatter.dateFormat = format.rawValue + + return (format, formatter) + } + ) + + static subscript(format: AppDateFormat) -> DateFormatter { + formatters[format]! + } + + /// DateFormatter가 다른 구분자까지 관대하게 허용하지 않도록 문자열 왕복 결과를 검사한다. + static func date(from value: String, format: AppDateFormat) -> Date? { + let formatter = self[format] + guard let date = formatter.date(from: value), formatter.string(from: date) == value else { + return nil + } + return date + } + + static func date( + from value: String, + format: AppDateFormat, + timeZone: TimeZone + ) -> Date? { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.calendar = calendar + formatter.dateFormat = format.rawValue + + guard let date = formatter.date(from: value), formatter.string(from: date) == value else { + return nil + } + return date + } +} + +public extension String { + var date: Date? { + guard + let date = AppDateFormatter.parser.date(from: self), + AppDateFormatter.parser.string(from: date) == self + else { + return nil + } + return date + } + + func date(as format: AppDateFormat) -> Date? { + AppDateFormatter.date(from: self, format: format) + } + + func date(as format: AppDateFormat, timeZone: TimeZone) -> Date? { + AppDateFormatter.date(from: self, format: format, timeZone: timeZone) + } +} + +public extension Date { + func formatted(_ format: AppDateFormat) -> String { + AppDateFormatter[format].string(from: self) + } + + func formatted(_ format: AppDateFormat, timeZone: TimeZone) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = format.rawValue + return formatter.string(from: self) + } +} diff --git a/Projects/Shared/Utill/Sources/Extension/Date+.swift b/Projects/Core/PickeCoreUtility/Sources/Date/Date+.swift similarity index 71% rename from Projects/Shared/Utill/Sources/Extension/Date+.swift rename to Projects/Core/PickeCoreUtility/Sources/Date/Date+.swift index 14077cba..1747310b 100644 --- a/Projects/Shared/Utill/Sources/Extension/Date+.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Date/Date+.swift @@ -1,22 +1,14 @@ // // Date+.swift -// Utill +// PickeCoreUtility // import Foundation public extension Date { - /// 지정 포맷 문자열로 변환 (ko_KR 고정). - func toString(format: String) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = format - return formatter.string(from: self) - } - /// `yyyy.M.d` 형식 (예: 2026.4.10). var yearMonthDayDot: String { - toString(format: "yyyy.M.d") + formatted(.yearMonthDayDotShort) } /// 한국어 상대 시간 (방금 전 / N분 전 / N시간 전 / N일 전). diff --git a/Projects/Core/PickeCoreUtility/Sources/Date/ServerDateParser.swift b/Projects/Core/PickeCoreUtility/Sources/Date/ServerDateParser.swift new file mode 100644 index 00000000..bed37768 --- /dev/null +++ b/Projects/Core/PickeCoreUtility/Sources/Date/ServerDateParser.swift @@ -0,0 +1,35 @@ +// +// ServerDateParser.swift +// PickeCoreUtility +// + +import Foundation + +/// 서버 시간 문자열 파서. +/// +/// 서버가 `2026-05-22T13:28:16.697Z`(타임존 포함) 또는 +/// `2026-05-22T13:28:16.921763` / `2026-05-22T13:28:16`(타임존 없는 LocalDateTime) 을 +/// 섞어 내려주므로 ISO8601 을 먼저 시도하고 타임존 없는 포맷까지 폴백한다. +/// (기존엔 타임존 없는 문자열이 nil 로 파싱돼 모든 댓글이 "방금 전"으로 표시되던 버그) +public enum ServerDateParser { + /// 타임존 없는 서버 시각은 서버 기준시(KST) 벽시계로 해석한다. + private static let naiveFormats: [AppDateFormat] = [ + .serverDateTimeMicros, + .serverDateTimeMillis, + .serverDateTime, + ] + + public static func parse(_ value: String) -> Date? { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = isoFormatter.date(from: value) { return date } + isoFormatter.formatOptions = [.withInternetDateTime] + if let date = isoFormatter.date(from: value) { return date } + + // 마이크로초(`.921763`)는 Date 정밀도로 왕복이 안 되므로 왕복 검증 없이 파싱한다. + for format in naiveFormats { + if let date = AppDateFormatter[format].date(from: value) { return date } + } + return nil + } +} diff --git a/Projects/Domain/Entity/Sources/Deeplink/PickeDeeplink.swift b/Projects/Core/PickeCoreUtility/Sources/Deeplink/PickeDeeplink.swift similarity index 79% rename from Projects/Domain/Entity/Sources/Deeplink/PickeDeeplink.swift rename to Projects/Core/PickeCoreUtility/Sources/Deeplink/PickeDeeplink.swift index e5fa0f09..b83fab8d 100644 --- a/Projects/Domain/Entity/Sources/Deeplink/PickeDeeplink.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Deeplink/PickeDeeplink.swift @@ -1,6 +1,6 @@ // // PickeDeeplink.swift -// Picke +// PickeCoreUtility // import Foundation @@ -14,6 +14,8 @@ public enum PickeDeeplink: Equatable, Sendable { case point /// POLICY_CHANGE → 서비스 약관 웹뷰. case terms + /// DAILY_MESSAGE → 빠른 배틀 탭. + case quickBattle /// 콜드 스타트 대기 딥링크 저장용 문자열 인코딩 (PickeDeeplinkParser.parse(urlString:) 로 복원). public var encoded: String { @@ -29,13 +31,26 @@ public enum PickeDeeplink: Equatable, Sendable { return "point" case .terms: return "terms" + case .quickBattle: + return "quick-battle" } } } public enum PickeDeeplinkParser { - /// 푸시 payload(userInfo) → 딥링크. iOS 는 notification+data 라 top-level 에 data 키가 존재. + /// 푸시 userInfo의 최상위 data 필드에서 딥링크를 읽는다. public static func parse(pushPayload userInfo: [AnyHashable: Any]) -> PickeDeeplink? { + // 구체적인 알림 코드가 우선이고, 해석할 수 없으면 기존 type / URL로 폴백한다. + if let detailCode = userInfo["detailCode"] as? String, + let deeplink = parse( + detailCode: detailCode, + referenceId: intValue(userInfo["referenceId"]) + ?? intValue(userInfo[detailCode == "NEW_BATTLE" ? "battleId" : "commentId"]), + perspectiveId: intValue(userInfo["perspectiveId"]) + ) + { + return deeplink + } switch userInfo["type"] as? String { case "BATTLE": if let battleId = intValue(userInfo["battleId"]) { @@ -78,6 +93,10 @@ public enum PickeDeeplinkParser { if let first = path.first, ["terms", "policy"].contains(first) { return .terms } + // 빠른 배틀도 단일 경로(picke://quick-battle). + if let first = path.first, ["quick-battle", "quickbattle"].contains(first) { + return .quickBattle + } guard path.count >= 2 else { return nil } switch path[0] { @@ -111,6 +130,8 @@ public enum PickeDeeplinkParser { return .point case "POLICY_CHANGE": return .terms + case "DAILY_MESSAGE": + return .quickBattle default: // PROMOTION 등은 이동 없음(텍스트만). return nil @@ -126,6 +147,6 @@ public enum PickeDeeplinkParser { } public extension Notification.Name { - /// 푸시/인앱 알림 탭으로 발생한 화면 이동 요청. userInfo["deeplink"] = PickeDeeplink.encoded + /// 인앱 알림은 userInfo["deeplink"]를 전달하고, 외부 진입은 저장된 대기 요청을 소비하도록 알린다. static let pickeDeeplink = Notification.Name("PickeDeeplink") } diff --git a/Projects/Shared/Utill/Sources/Extension/Int+.swift b/Projects/Core/PickeCoreUtility/Sources/Extension/Int+.swift similarity index 97% rename from Projects/Shared/Utill/Sources/Extension/Int+.swift rename to Projects/Core/PickeCoreUtility/Sources/Extension/Int+.swift index 867ea750..54f1500b 100644 --- a/Projects/Shared/Utill/Sources/Extension/Int+.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Extension/Int+.swift @@ -1,6 +1,6 @@ // // Int+DecimalFormat.swift -// Utill +// PickeCoreUtility // import Foundation diff --git a/Projects/Shared/Utill/Sources/Extension/String+.swift b/Projects/Core/PickeCoreUtility/Sources/Extension/String+.swift similarity index 97% rename from Projects/Shared/Utill/Sources/Extension/String+.swift rename to Projects/Core/PickeCoreUtility/Sources/Extension/String+.swift index 342ed51a..8ed0a143 100644 --- a/Projects/Shared/Utill/Sources/Extension/String+.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Extension/String+.swift @@ -1,6 +1,6 @@ // // String+Sentence.swift -// Utill +// PickeCoreUtility // import Foundation diff --git a/Projects/Shared/Utill/Sources/Extension/UUID+.swift b/Projects/Core/PickeCoreUtility/Sources/Extension/UUID+.swift similarity index 95% rename from Projects/Shared/Utill/Sources/Extension/UUID+.swift rename to Projects/Core/PickeCoreUtility/Sources/Extension/UUID+.swift index 52375120..1124b2a1 100644 --- a/Projects/Shared/Utill/Sources/Extension/UUID+.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Extension/UUID+.swift @@ -1,6 +1,6 @@ // // UUID+Deterministic.swift -// Utill +// PickeCoreUtility // import Foundation diff --git a/Projects/Shared/Shared/Sources/FeatureFlag.swift b/Projects/Core/PickeCoreUtility/Sources/FeatureFlag/FeatureFlag.swift similarity index 100% rename from Projects/Shared/Shared/Sources/FeatureFlag.swift rename to Projects/Core/PickeCoreUtility/Sources/FeatureFlag/FeatureFlag.swift diff --git a/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift b/Projects/Core/PickeCoreUtility/Sources/Share/PickeShareURL.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Share/PickeShareURL.swift rename to Projects/Core/PickeCoreUtility/Sources/Share/PickeShareURL.swift diff --git a/Projects/Domain/Entity/Sources/Share/ShareContent.swift b/Projects/Core/PickeCoreUtility/Sources/Share/ShareContent.swift similarity index 94% rename from Projects/Domain/Entity/Sources/Share/ShareContent.swift rename to Projects/Core/PickeCoreUtility/Sources/Share/ShareContent.swift index faa1642f..5dbcd338 100644 --- a/Projects/Domain/Entity/Sources/Share/ShareContent.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Share/ShareContent.swift @@ -1,11 +1,11 @@ // // ShareContent.swift -// Entity +// PickeCoreUtility // import Foundation -public struct ShareContent: Equatable { +public struct ShareContent: Equatable, Sendable { public let title: String public let summary: String public let hashtags: [String] diff --git a/Projects/Domain/Entity/Sources/Share/ShareItem.swift b/Projects/Core/PickeCoreUtility/Sources/Share/ShareItem.swift similarity index 76% rename from Projects/Domain/Entity/Sources/Share/ShareItem.swift rename to Projects/Core/PickeCoreUtility/Sources/Share/ShareItem.swift index 2883bdde..0fda3c1b 100644 --- a/Projects/Domain/Entity/Sources/Share/ShareItem.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Share/ShareItem.swift @@ -1,11 +1,11 @@ // // ShareItem.swift -// Entity +// PickeCoreUtility // import Foundation -public struct ShareItem: Equatable, Identifiable { +public struct ShareItem: Equatable, Identifiable, @unchecked Sendable { public let id: UUID public let items: [Any] diff --git a/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift b/Projects/Core/PickeCoreUtility/Sources/Share/ShareUseCase.swift similarity index 64% rename from Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift rename to Projects/Core/PickeCoreUtility/Sources/Share/ShareUseCase.swift index 531f82f2..34ac24f3 100644 --- a/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift +++ b/Projects/Core/PickeCoreUtility/Sources/Share/ShareUseCase.swift @@ -1,19 +1,17 @@ // // ShareUseCase.swift -// UseCase +// PickeCoreUtility // import Foundation import UIKit -import ComposableArchitecture -import Entity +import Dependencies +import PickeNetwork -/// 공유 시트 아이템(본문·링크·이미지)을 조립하는 클라이언트. +/// 공유 시트 아이템(본문, 링크, 이미지)을 조립하는 클라이언트. public struct ShareUseCase: Sendable { - /// 원격 이미지를 받음 public var loadImageData: @Sendable (_ urlString: String) async -> Data? - /// `ShareContent` 를 공유 시트에 넘길 아이템 배열로 조립한다. public var makeShareItem: @Sendable (_ content: ShareContent) async -> ShareItem public init( @@ -37,7 +35,6 @@ extension ShareUseCase: DependencyKey { items.append(content.url) } - // 인스타 스토리/게시물 공유를 위해 카드 스냅샷을 우선 포함하고, 없을 때만 썸네일로 폴백. if let snapshotData = content.snapshotData, let image = UIImage(data: snapshotData) { items.append(image) } else if let thumbnailURL = content.thumbnailURL, @@ -59,10 +56,35 @@ extension ShareUseCase: DependencyKey { public static let previewValue = testValue private static let loadRemoteImageData: @Sendable (String) async -> Data? = { urlString in - guard let url = URL(string: urlString), - let (data, _) = try? await URLSession.shared.data(from: url) - else { return nil } - return data + guard let url = URL(string: urlString) else { return nil } + let startedAt = Date() + + do { + let (data, response) = try await URLSession.shared.data(from: url) + NetworkTelemetry.shared.record( + NetworkTelemetryEvent( + source: "vote_share_image", + method: "GET", + url: url, + statusCode: (response as? HTTPURLResponse)?.statusCode, + duration: Date().timeIntervalSince(startedAt), + isSuccess: true + ) + ) + return data + } catch { + NetworkTelemetry.shared.record( + NetworkTelemetryEvent( + source: "vote_share_image", + method: "GET", + url: url, + statusCode: nil, + duration: Date().timeIntervalSince(startedAt), + isSuccess: false + ) + ) + return nil + } } } diff --git a/Projects/Core/PickeCoreUtility/Tests/Sources/FormattingExtensionTests.swift b/Projects/Core/PickeCoreUtility/Tests/Sources/FormattingExtensionTests.swift new file mode 100644 index 00000000..5c1f0d2e --- /dev/null +++ b/Projects/Core/PickeCoreUtility/Tests/Sources/FormattingExtensionTests.swift @@ -0,0 +1,41 @@ +// +// FormattingExtensionTests.swift +// PickeCoreUtilityTests +// + +import Foundation +import Testing + +@testable import PickeCoreUtility + +struct IntFormattingTests { + @Test + func 재생시간은_분과_초를_함께_표기한다() { + #expect(125.durationText == "2분 5초") + #expect(120.durationText == "2분") + #expect(45.durationText == "45초") + } + + @Test + func 반올림_분_표기는_1분_미만도_1분으로_올린다() { + #expect(20.roundedMinuteText == "1분") + #expect(100.roundedMinuteText == "2분") + } +} + +struct StringSentenceTests { + @Test + func 문장부호를_포함해_분할한다() { + #expect("안녕. 반가워! 잘가?".splitIntoSentences() == ["안녕.", " 반가워!", " 잘가?"]) + } + + @Test + func 공백뿐인_조각은_버린다() { + #expect("안녕. \n".splitIntoSentences() == ["안녕."]) + } + + @Test + func 문장부호가_없으면_원본_하나를_돌려준다() { + #expect("문장부호 없음".splitIntoSentences() == ["문장부호 없음"]) + } +} diff --git a/Projects/Core/PickeCoreUtility/Tests/Sources/PickeDeeplinkParserTests.swift b/Projects/Core/PickeCoreUtility/Tests/Sources/PickeDeeplinkParserTests.swift new file mode 100644 index 00000000..758827b5 --- /dev/null +++ b/Projects/Core/PickeCoreUtility/Tests/Sources/PickeDeeplinkParserTests.swift @@ -0,0 +1,135 @@ +// +// PickeDeeplinkParserTests.swift +// PickeCoreUtilityTests +// + +import Foundation +import Testing + +@testable import PickeCoreUtility + +struct PickeDeeplinkParserTests { + @Test + func 커스텀_스킴의_host_를_리소스_타입으로_읽는다() { + #expect(PickeDeeplinkParser.parse(urlString: "picke://battle/55") == .battle(battleId: 55)) + } + + @Test + func 유니버설_링크의_commentId_쿼리를_함께_읽는다() { + let link = PickeDeeplinkParser.parse(urlString: "https://picke.store/perspective/45?commentId=678") + + #expect(link == .perspective(perspectiveId: 45, commentId: 678)) + } + + @Test + func 단일_경로는_포인트와_약관으로_매핑된다() { + #expect(PickeDeeplinkParser.parse(urlString: "picke://credits") == .point) + #expect(PickeDeeplinkParser.parse(urlString: "picke://policy") == .terms) + } + + @Test + func 식별자가_없으면_딥링크를_만들지_않는다() { + #expect(PickeDeeplinkParser.parse(urlString: "picke://battle") == nil) + #expect(PickeDeeplinkParser.parse(urlString: "picke://battle/none") == nil) + } + + @Test + func 푸시_payload_의_문자열_식별자도_숫자로_읽는다() { + let link = PickeDeeplinkParser.parse(pushPayload: [ + "type": "COMMENT", + "perspectiveId": "45", + "commentId": 678, + ]) + + #expect(link == .perspective(perspectiveId: 45, commentId: 678)) + } + + @Test + func DAILY_MESSAGE_푸시는_빠른_배틀로_이동한다() { + #expect( + PickeDeeplinkParser.parse(pushPayload: ["detailCode": "DAILY_MESSAGE"]) == .quickBattle + ) + #expect( + PickeDeeplinkParser.parse(pushPayload: [ + "detailCode": "DAILY_MESSAGE", + "type": "BATTLE", + "battleId": "7", + "url": "picke://battle/7", + ]) == .quickBattle + ) + } + + @Test + func 푸시와_알림함은_같은_detailCode_매핑을_사용한다() { + for code in ["NEW_BATTLE", "NEW_COMMENT", "COMMENT_LIKE", "CREDIT_EARNED", "POLICY_CHANGE", "DAILY_MESSAGE"] { + #expect( + PickeDeeplinkParser.parse(pushPayload: [ + "detailCode": code, + "referenceId": "9", + "perspectiveId": "4", + ]) == PickeDeeplinkParser.parse( + detailCode: code, + referenceId: 9, + perspectiveId: 4 + ) + ) + } + } + + @Test + func 매핑할_수_없는_detailCode는_type과_URL로_폴백한다() { + #expect(PickeDeeplinkParser.parse(pushPayload: [ + "detailCode": "UNKNOWN", + "type": "BATTLE", + "battleId": "7", + "url": "picke://point", + ]) == .battle(battleId: 7)) + #expect(PickeDeeplinkParser.parse(pushPayload: [ + "detailCode": "NEW_BATTLE", + "url": "picke://point", + ]) == .point) + #expect(PickeDeeplinkParser.parse(pushPayload: ["detailCode": "UNKNOWN"]) == nil) + } + + @Test + func 푸시_type_이_없으면_url_로_폴백한다() { + let link = PickeDeeplinkParser.parse(pushPayload: ["url": "picke://battle/7"]) + + #expect(link == .battle(battleId: 7)) + } + + @Test + func 알림함_detailCode_를_딥링크로_바꾼다() { + #expect( + PickeDeeplinkParser.parse(detailCode: "NEW_BATTLE", referenceId: 3, perspectiveId: nil) + == .battle(battleId: 3) + ) + #expect( + PickeDeeplinkParser.parse(detailCode: "NEW_COMMENT", referenceId: 9, perspectiveId: 4) + == .perspective(perspectiveId: 4, commentId: 9) + ) + #expect( + PickeDeeplinkParser.parse(detailCode: "DAILY_MESSAGE", referenceId: nil, perspectiveId: nil) + == .quickBattle + ) + #expect( + PickeDeeplinkParser.parse(detailCode: "PROMOTION", referenceId: 1, perspectiveId: 1) == nil + ) + } + + @Test + func encoded_문자열은_다시_같은_딥링크로_복원된다() { + let links: [PickeDeeplink] = [ + .battle(battleId: 55), + .perspective(perspectiveId: 45, commentId: 678), + .perspective(perspectiveId: 45, commentId: nil), + .point, + .terms, + .quickBattle, + ] + + for link in links { + #expect(PickeDeeplinkParser.parse(urlString: "picke://\(link.encoded)") == link) + } + } +} diff --git a/Projects/Core/PickeCoreUtility/Tests/Sources/PickeShareTests.swift b/Projects/Core/PickeCoreUtility/Tests/Sources/PickeShareTests.swift new file mode 100644 index 00000000..67732dc4 --- /dev/null +++ b/Projects/Core/PickeCoreUtility/Tests/Sources/PickeShareTests.swift @@ -0,0 +1,63 @@ +// +// PickeShareTests.swift +// PickeCoreUtilityTests +// + +import Foundation +import Testing + +@testable import PickeCoreUtility + +struct PickeShareURLTests { + @Test + func 공유_링크는_실존하는_battle_단수_경로를_사용한다() { + #expect(PickeShareURL.battle(id: 42) == "https://picke.store/battle/42") + } + + @Test + func 신뢰할_수_있는_picke_store_서버_링크만_사용한다() { + #expect( + PickeShareURL.battle( + id: 42, + serverShareUrl: "https://picke.store/battle/99" + ) == "https://picke.store/battle/99" + ) + #expect( + PickeShareURL.battle( + id: 42, + serverShareUrl: "https://preview.picke.store/battle/99" + ) == "https://preview.picke.store/battle/99" + ) + } + + @Test + func 신뢰할_수_없는_서버_링크는_검증된_랜딩_경로로_대체한다() { + #expect( + PickeShareURL.battle( + id: 42, + serverShareUrl: "https://pique.app/battles/42" + ) == "https://picke.store/battle/42" + ) + } +} + +struct ShareUseCaseTests { + @Test + func 라이브_공유_클라이언트는_본문과_URL을_조립한다() async { + let item = await ShareUseCase.liveValue.makeShareItem( + ShareContent( + title: "타이틀", + summary: "요약", + hashtags: ["#picke"], + optionLine: nil, + url: "https://picke.store/battle/42", + thumbnailURL: nil, + snapshotData: nil + ) + ) + + #expect(item.items.count == 2) + #expect(item.items.first as? String == "타이틀\n\n요약\n\n#picke") + #expect(item.items.last as? URL == URL(string: "https://picke.store/battle/42")) + } +} diff --git a/Projects/Core/PickeCoreUtility/Tests/Sources/ServerDateParserTests.swift b/Projects/Core/PickeCoreUtility/Tests/Sources/ServerDateParserTests.swift new file mode 100644 index 00000000..eec8060a --- /dev/null +++ b/Projects/Core/PickeCoreUtility/Tests/Sources/ServerDateParserTests.swift @@ -0,0 +1,45 @@ +// +// ServerDateParserTests.swift +// PickeCoreUtilityTests +// + +import Foundation +import Testing + +@testable import PickeCoreUtility + +struct ServerDateParserTests { + @Test + func 타임존이_붙은_ISO8601_을_읽는다() throws { + let date = try #require(ServerDateParser.parse("2026-05-22T13:28:16.697Z")) + + #expect(date.timeIntervalSince1970 == 1_779_456_496.697) + } + + @Test + func 소수점이_없는_ISO8601_도_읽는다() throws { + let date = try #require(ServerDateParser.parse("2026-05-22T13:28:16Z")) + + #expect(date.timeIntervalSince1970 == 1_779_456_496) + } + + /// 타임존 없는 LocalDateTime 이 nil 로 떨어져 모든 댓글이 "방금 전"으로 보이던 회귀 방지. + @Test + func 타임존이_없으면_KST_벽시계로_해석한다() throws { + let naive = try #require(ServerDateParser.parse("2026-05-22T13:28:16")) + let sameMoment = try #require(ServerDateParser.parse("2026-05-22T04:28:16Z")) + + #expect(naive == sameMoment) + } + + @Test + func 마이크로초까지_내려와도_읽는다() { + #expect(ServerDateParser.parse("2026-05-22T13:28:16.921763") != nil) + } + + @Test + func 형식에_맞지_않으면_nil_이다() { + #expect(ServerDateParser.parse("2026/05/22 13:28") == nil) + #expect(ServerDateParser.parse("") == nil) + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Auth/CredentialStore.swift b/Projects/Core/PickeNetwork/Interface/Auth/CredentialStore.swift new file mode 100644 index 00000000..f7639efa --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Auth/CredentialStore.swift @@ -0,0 +1,16 @@ +// +// CredentialStore.swift +// PickeNetworkInterface +// + +import Foundation + +/// 토큰 저장소 추상. 앱이 구현(키체인 등)해 주입한다. +public protocol CredentialStore: Sendable { + /// 저장된 토큰 (없으면 nil) + func load() -> PickeCredential? + /// 토큰 저장 (refresh 후 영속화) + func save(_ credential: PickeCredential) + /// 토큰 삭제 (로그아웃 등) + func clear() +} diff --git a/Projects/Core/PickeNetwork/Interface/Auth/CredentialUpdating.swift b/Projects/Core/PickeNetwork/Interface/Auth/CredentialUpdating.swift new file mode 100644 index 00000000..3dfea1aa --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Auth/CredentialUpdating.swift @@ -0,0 +1,11 @@ +// +// CredentialUpdating.swift +// PickeNetworkInterface +// + +import Foundation + +/// 인증 세션의 credential 을 외부에서 교체하는 핸들. +public protocol CredentialUpdating: Sendable { + func update(_ credential: PickeCredential?) +} diff --git a/Projects/Core/PickeNetwork/Interface/Auth/PickeAuthorization.swift b/Projects/Core/PickeNetwork/Interface/Auth/PickeAuthorization.swift new file mode 100644 index 00000000..7ae23134 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Auth/PickeAuthorization.swift @@ -0,0 +1,14 @@ +// +// PickeAuthorization.swift +// PickeNetworkInterface +// + +import Foundation + +/// 요청의 인증 정책. 토큰 부착 여부는 요청 시점에 credential 유무로 판단한다. +public enum PickeAuthorization: Sendable, Equatable { + /// 로그인 상태면 토큰 부착 + refresh/재시도, 아니면 토큰 없이 전송 (기본값) + case automatic + /// 인증 파이프라인 완전 우회 — 로그인 상태여도 토큰을 붙이지 않고, 401 에도 refresh 하지 않는다 + case none +} diff --git a/Projects/Core/PickeNetwork/Interface/Auth/PickeCredential.swift b/Projects/Core/PickeNetwork/Interface/Auth/PickeCredential.swift new file mode 100644 index 00000000..8d3a6133 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Auth/PickeCredential.swift @@ -0,0 +1,36 @@ +// +// PickeCredential.swift +// PickeNetworkInterface +// + +import Foundation + +import Alamofire + +/// 인증 토큰 묶음. +public struct PickeCredential: AuthenticationCredential, Sendable, Equatable { + public let accessToken: String + public let refreshToken: String + /// 액세스 토큰 만료 시각 (nil 이면 만료 판정 안 함 — 401 을 받고 나서야 갱신한다) + public let expiresAt: Date? + /// 만료 직전 미리 갱신할 여유 시간(초). 만료 round-trip / 401 을 피하려고 둔다. + public let refreshLeeway: TimeInterval + + public init( + accessToken: String, + refreshToken: String, + expiresAt: Date? = nil, + refreshLeeway: TimeInterval = 5 * 60 + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAt = expiresAt + self.refreshLeeway = refreshLeeway + } + + /// 만료(leeway 포함) 임박 시 refresh 필요. + public var requiresRefresh: Bool { + guard let expiresAt else { return false } + return Date() >= expiresAt.addingTimeInterval(-refreshLeeway) + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Auth/TokenRefreshing.swift b/Projects/Core/PickeNetwork/Interface/Auth/TokenRefreshing.swift new file mode 100644 index 00000000..23a1d8f7 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Auth/TokenRefreshing.swift @@ -0,0 +1,11 @@ +// +// TokenRefreshing.swift +// PickeNetworkInterface +// + +import Foundation + +/// 토큰 refresh 추상. refresh API 호출은 앱(Repository)이 구현한다. +public protocol TokenRefreshing: Sendable { + func refresh(_ current: PickeCredential) async throws(PickeNetworkError) -> PickeCredential +} diff --git a/Projects/Core/PickeNetwork/Interface/Client/PickeFileUploadClient.swift b/Projects/Core/PickeNetwork/Interface/Client/PickeFileUploadClient.swift new file mode 100644 index 00000000..84a3b54d --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Client/PickeFileUploadClient.swift @@ -0,0 +1,12 @@ +// +// PickeFileUploadClient.swift +// PickeNetworkInterface +// + +import Foundation + +/// presigned URL 원본 바이트 업로드 진입점. +public protocol PickeFileUploadClient: Sendable { + /// presigned URL 에 원본 바이트를 PUT 업로드한다. + func upload(_ request: some PickeFileUploadRequest) async throws(PickeNetworkError) +} diff --git a/Projects/Core/PickeNetwork/Interface/Client/PickeNetworkClient.swift b/Projects/Core/PickeNetwork/Interface/Client/PickeNetworkClient.swift new file mode 100644 index 00000000..f5033a95 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Client/PickeNetworkClient.swift @@ -0,0 +1,9 @@ +// +// PickeNetworkClient.swift +// PickeNetworkInterface +// + +import Foundation + +/// 일반 요청 / 멀티파트 업로드 / 파일 PUT 을 한 계약으로 묶은 네트워크 클라이언트. +public protocol PickeNetworkClient: PickeRequestClient, PickeUploadClient, PickeFileUploadClient {} diff --git a/Projects/Core/PickeNetwork/Interface/Client/PickeRequestClient.swift b/Projects/Core/PickeNetwork/Interface/Client/PickeRequestClient.swift new file mode 100644 index 00000000..6376b47e --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Client/PickeRequestClient.swift @@ -0,0 +1,21 @@ +// +// PickeRequestClient.swift +// PickeNetworkInterface +// + +import Foundation + +/// 네트워크 요청 진입점. +public protocol PickeRequestClient: Sendable { + /// 요청을 보내고 호출부가 지정한 타입으로 디코딩한다. + func send( + _ request: R, + as type: T.Type + ) async throws(PickeNetworkError) -> T + + /// 요청 타입에 선언된 `Response` 로 디코딩한다. + func send(_ request: R) async throws(PickeNetworkError) -> R.Response + + /// 상태 코드와 원시 바디를 호출부가 직접 해석해야 하는 요청에 사용한다. + func sendResponse(_ request: R) async throws(PickeNetworkError) -> PickeHTTPResponse +} diff --git a/Projects/Core/PickeNetwork/Interface/Client/PickeUploadClient.swift b/Projects/Core/PickeNetwork/Interface/Client/PickeUploadClient.swift new file mode 100644 index 00000000..590a67ba --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Client/PickeUploadClient.swift @@ -0,0 +1,12 @@ +// +// PickeUploadClient.swift +// PickeNetworkInterface +// + +import Foundation + +/// 멀티파트 업로드 진입점. +public protocol PickeUploadClient: Sendable { + /// 멀티파트 요청을 전송하고 디코딩된 응답을 반환한다. + func upload(_ request: R) async throws(PickeNetworkError) -> R.Response +} diff --git a/Projects/Core/PickeNetwork/Interface/Dependency/NetworkClientDependency.swift b/Projects/Core/PickeNetwork/Interface/Dependency/NetworkClientDependency.swift new file mode 100644 index 00000000..b1468d5b --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Dependency/NetworkClientDependency.swift @@ -0,0 +1,61 @@ +// +// NetworkClientDependency.swift +// PickeNetworkInterface +// +// 네트워크 클라이언트의 DependencyKey. +// +// 실제 인증 클라이언트는 ServiceAssembly 가 `DependencyValues` 에 등록한다. +// Interface 모듈에는 계약과 테스트 기본값만 둔다. +// + +import Foundation + +import Dependencies + +public enum NetworkClientDependency: TestDependencyKey { + public static var testValue: any PickeNetworkClient { + UnimplementedNetworkClient() + } +} + +public extension DependencyValues { + var networkClient: any PickeNetworkClient { + get { self[NetworkClientDependency.self] } + set { self[NetworkClientDependency.self] = newValue } + } +} + +/// 테스트에서 클라이언트를 갈아끼우지 않은 채 네트워크를 타면 알려주는 기본값. +public struct UnimplementedNetworkClient: PickeNetworkClient { + public init() {} + + public func send( + _: R, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + reportUnimplemented() + } + + public func send(_: R) async throws(PickeNetworkError) -> R.Response { + reportUnimplemented() + } + + public func sendResponse(_: R) async throws(PickeNetworkError) -> PickeHTTPResponse { + reportUnimplemented() + } + + public func upload(_: R) async throws(PickeNetworkError) -> R.Response { + reportUnimplemented() + } + + public func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + reportUnimplemented() + } + + private func reportUnimplemented() -> Never { + fatalError( + "networkClient 가 등록되지 않았다. 테스트라면 withDependencies 로 스텁을 넣고, " + + "앱이라면 ServiceAssembly 의 liveValue 등록을 확인할 것." + ) + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Endpoint/PickeDataRequest.swift b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeDataRequest.swift new file mode 100644 index 00000000..5b110ed7 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeDataRequest.swift @@ -0,0 +1,28 @@ +// +// PickeDataRequest.swift +// PickeNetworkInterface +// + +import Foundation + +import Alamofire + +/// 일반(비-멀티파트) 요청. 엔드포인트 메타는 `PickeEndpoint`, 바디는 `parameters` 로 표현한다. +public protocol PickeDataRequest: PickeEndpoint { + /// 디코딩될 응답 타입 + associatedtype Response: Decodable & Sendable = PickeEmptyResponse + /// 요청 파라미터(Encodable). 쿼리 / 바디 위치는 인코더가 결정한다. + var parameters: (any Encodable & Sendable)? { get } + /// 파라미터 인코더 오버라이드. nil 이면 method 기준 기본(GET/DELETE 쿼리스트링, 그 외 JSON 바디). + var parameterEncoder: ParameterEncoder? { get } +} + +public extension PickeDataRequest { + var parameters: (any Encodable & Sendable)? { + nil + } + + var parameterEncoder: ParameterEncoder? { + nil + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Endpoint/PickeEndpoint.swift b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeEndpoint.swift new file mode 100644 index 00000000..a7fce32f --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeEndpoint.swift @@ -0,0 +1,60 @@ +// +// PickeEndpoint.swift +// PickeNetworkInterface +// + +import Foundation + +import Alamofire + +/// 엔드포인트가 속한 도메인. base URL 과 도메인 경로 접두사를 묶는다. +public protocol PickeDomainType: Sendable { + var baseURLString: String { get } + var url: String { get } +} + +/// 일반 요청(`PickeDataRequest`)·멀티파트 업로드(`PickeUploadRequest`)가 공유하는 엔드포인트 메타. +public protocol PickeEndpoint { + /// 엔드포인트가 속한 도메인 (base URL + 경로 접두사) + var domain: any PickeDomainType { get } + /// 도메인 경로 뒤에 붙는 경로 (예: "3/participation") + var path: String { get } + /// HTTP 메서드 + var method: HTTPMethod { get } + /// 요청 헤더 (공통 헤더는 세션이 붙인다 — 여기엔 이 요청만의 헤더를 둔다) + var headers: HTTPHeaders { get } + /// 타임아웃(초). nil 이면 세션 기본값 사용. + var timeoutInterval: TimeInterval? { get } + /// 최대 재시도 횟수 (0 = 재시도 안 함) + var maxRetryAttempts: Int { get } + /// 인증 정책. 기본값 `.automatic` — 예외 엔드포인트만 선언한다. + var authorization: PickeAuthorization { get } +} + +public extension PickeEndpoint { + var headers: HTTPHeaders { + [:] + } + + var timeoutInterval: TimeInterval? { + nil + } + + /// 기본 재시도 횟수. + var maxRetryAttempts: Int { + 3 + } + + /// 기본 인증 정책 — 로그인 상태면 토큰 부착. + var authorization: PickeAuthorization { + .automatic + } + + /// `baseURLString + domain.url + path` 로 조합한 최종 URL. + func url() throws(PickeNetworkError) -> URL { + guard let base = URL(string: domain.baseURLString) else { + throw .request(.invalidURL) + } + return base.appendingPathComponent(domain.url + path) + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Endpoint/PickeFileUploadRequest.swift b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeFileUploadRequest.swift new file mode 100644 index 00000000..4da6e271 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeFileUploadRequest.swift @@ -0,0 +1,24 @@ +// +// PickeFileUploadRequest.swift +// PickeNetworkInterface +// + +import Foundation + +/// presigned URL 에 원본 바이트를 PUT 업로드하는 요청. +public protocol PickeFileUploadRequest: Sendable { + /// 업로드 대상 presigned URL + var uploadURL: URL { get } + /// PUT 으로 실어 보낼 원본 바이트 + var body: Data { get } + /// 업로드 바이트의 MIME 타입 + var contentType: String { get } + /// 타임아웃(초). nil 이면 세션 기본값 사용. + var timeoutInterval: TimeInterval? { get } +} + +public extension PickeFileUploadRequest { + var timeoutInterval: TimeInterval? { + nil + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Endpoint/PickeUploadRequest.swift b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeUploadRequest.swift new file mode 100644 index 00000000..db26cb69 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Endpoint/PickeUploadRequest.swift @@ -0,0 +1,14 @@ +// +// PickeUploadRequest.swift +// PickeNetworkInterface +// + +import Foundation + +/// 멀티파트 업로드 요청. 엔드포인트 메타는 `PickeEndpoint`, 바디는 `parts` 로 표현한다. +public protocol PickeUploadRequest: PickeEndpoint { + /// 디코딩될 응답 타입 + associatedtype Response: Decodable & Sendable = PickeEmptyResponse + /// 멀티파트 바디를 구성하는 파트들 + var parts: [PickeMultipartPart] { get } +} diff --git a/Projects/Core/PickeNetwork/Interface/Error/DecodingFailure.swift b/Projects/Core/PickeNetwork/Interface/Error/DecodingFailure.swift new file mode 100644 index 00000000..7d5cf672 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Error/DecodingFailure.swift @@ -0,0 +1,14 @@ +// +// DecodingFailure.swift +// PickeNetworkInterface +// + +import Foundation + +/// 응답 파싱 — 서버 객체를 클라 객체로 디코딩하다 실패. +public enum DecodingFailure: Error { + /// 본문을 기대한 요청인데 응답 바디가 비어 있음 (`PickeEmptyResponse` 가 아닌 경우) + case dataMissing + /// 디코딩 자체 실패 + case failed(any Error) +} diff --git a/Projects/Core/PickeNetwork/Interface/Error/PickeNetworkError.swift b/Projects/Core/PickeNetwork/Interface/Error/PickeNetworkError.swift new file mode 100644 index 00000000..92175f97 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Error/PickeNetworkError.swift @@ -0,0 +1,18 @@ +// +// PickeNetworkError.swift +// PickeNetworkInterface +// + +import Foundation + +/// 네트워크 도메인 에러. 요청 파이프라인의 "어느 단계"에서 깨졌는지로 분류한다. +public enum PickeNetworkError: Error { + /// 전송 전 — URLRequest 생성 / 파라미터 인코딩 단계 오류 + case request(RequestError) + /// 전송 중 — 연결 실패 / 타임아웃 / 취소 등 + case transport(TransportError) + /// 서버가 명시적으로 내려준 에러 응답 (4xx/5xx) + case response(ResponseError) + /// 응답 파싱 — 서버 객체를 클라 객체로 디코딩하다 실패 + case decoding(DecodingFailure) +} diff --git a/Projects/Core/PickeNetwork/Interface/Error/RequestError.swift b/Projects/Core/PickeNetwork/Interface/Error/RequestError.swift new file mode 100644 index 00000000..22c3d219 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Error/RequestError.swift @@ -0,0 +1,14 @@ +// +// RequestError.swift +// PickeNetworkInterface +// + +import Foundation + +/// 전송 전 — URLRequest 생성 / 파라미터 인코딩 단계 오류. +public enum RequestError: Error { + /// baseURL 이 없거나 경로 조합이 잘못됨 + case invalidURL + /// 파라미터 인코딩 실패 + case encodingFailed(any Error) +} diff --git a/Projects/Core/PickeNetwork/Interface/Error/ResponseError.swift b/Projects/Core/PickeNetwork/Interface/Error/ResponseError.swift new file mode 100644 index 00000000..0dfa74ff --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Error/ResponseError.swift @@ -0,0 +1,36 @@ +// +// ResponseError.swift +// PickeNetworkInterface +// + +import Foundation + +/// 서버가 명시적으로 내려준 에러 응답. +public struct ResponseError: Error, Sendable, Equatable { + /// HTTP status + public let httpStatus: Int + /// 서버 에러 코드 (예: "BATTLE_NOT_FOUND"). 없을 수 있다. + public let code: String? + /// 서버 에러 메시지 + public let message: String? + + public init( + httpStatus: Int, + code: String? = nil, + message: String? = nil + ) { + self.httpStatus = httpStatus + self.code = code + self.message = message + } + + /// 인증 실패 — 토큰 갱신/재로그인 분기용. + public var isUnauthorized: Bool { + httpStatus == 401 + } + + /// 5xx 인프라성 에러 여부 + public var isServerError: Bool { + (500 ..< 600).contains(httpStatus) + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Error/TransportError.swift b/Projects/Core/PickeNetwork/Interface/Error/TransportError.swift new file mode 100644 index 00000000..e3a5ef79 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Error/TransportError.swift @@ -0,0 +1,18 @@ +// +// TransportError.swift +// PickeNetworkInterface +// + +import Foundation + +/// 전송 중 — 연결 / 타임아웃 / 취소 등 전송 자체 실패. +public enum TransportError: Error { + /// 네트워크 연결 없음 (오프라인 등) + case notConnected + /// 타임아웃 + case timedOut + /// 요청 취소 + case cancelled + /// 그 외 전송 실패 + case unknown(any Error) +} diff --git a/Projects/Core/PickeNetwork/Interface/Header/APIHeader.swift b/Projects/Core/PickeNetwork/Interface/Header/APIHeader.swift new file mode 100644 index 00000000..70a06d03 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Header/APIHeader.swift @@ -0,0 +1,16 @@ +// +// APIHeader.swift +// PickeNetworkInterface +// +// Created by Wonji Suh on 5/7/25. +// + +import Foundation + +/// 요청 헤더 키 상수. +public enum APIHeader { + public static let contentType = "Content-Type" + public static let accessToken = "Authorization" + public static let refreshToken = "X-Refresh-Token" + public static let accept = "accept" +} diff --git a/Projects/Core/PickeNetwork/Interface/Header/APIHeaderManger.swift b/Projects/Core/PickeNetwork/Interface/Header/APIHeaderManger.swift new file mode 100644 index 00000000..c45d1511 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Header/APIHeaderManger.swift @@ -0,0 +1,15 @@ +// +// APIHeaderManger.swift +// PickeNetworkInterface +// +// Created by Wonji Suh on 5/7/25. +// + +import Foundation + +public enum APIHeaderManger { + public static let appPackageName: String = "-" + public static let contentType: String = "application/json" + public static let multipartContentType: String = "multipart/form-data" + public static let contentAppleType: String = "application/x-www-form-urlencoded" +} diff --git a/Projects/Data/Model/Sources/Common/APIErrorDTO.swift b/Projects/Core/PickeNetwork/Interface/Model/APIErrorDTO.swift similarity index 60% rename from Projects/Data/Model/Sources/Common/APIErrorDTO.swift rename to Projects/Core/PickeNetwork/Interface/Model/APIErrorDTO.swift index b2d15ae4..9ec08742 100644 --- a/Projects/Data/Model/Sources/Common/APIErrorDTO.swift +++ b/Projects/Core/PickeNetwork/Interface/Model/APIErrorDTO.swift @@ -1,19 +1,11 @@ // // APIErrorDTO.swift -// Model -// -// Created by Wonji Suh on 5/14/26. +// PickeNetworkInterface // import Foundation -/// 서버 공통 에러 응답 -/// ```json -/// "error": { -/// "code": "string", -/// "message": "string" -/// } -/// ``` +/// 서버 공통 에러 응답 — `"error": { "code": ..., "message": ... }`. public struct APIErrorDTO: Decodable, Equatable { public let code: String public let message: String diff --git a/Projects/Core/PickeNetwork/Interface/Model/PickeEmptyResponse.swift b/Projects/Core/PickeNetwork/Interface/Model/PickeEmptyResponse.swift new file mode 100644 index 00000000..b436a8a2 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Model/PickeEmptyResponse.swift @@ -0,0 +1,20 @@ +// +// PickeEmptyResponse.swift +// PickeNetworkInterface +// + +import Foundation + +import Alamofire + +/// 본문(payload)이 없는 응답. 성공만 받으면 되는 요청(POST/DELETE 등)의 +public struct PickeEmptyResponse: Decodable, Sendable, EmptyResponse { + public init() {} + + /// 바디가 `{}` 가 아니어도(빈 배열·빈 문자열 등) 실패하지 않도록 키 컨테이너를 열지 않는다. + public init(from _: any Decoder) {} + + public static func emptyValue() -> PickeEmptyResponse { + PickeEmptyResponse() + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Model/PickeHTTPResponse.swift b/Projects/Core/PickeNetwork/Interface/Model/PickeHTTPResponse.swift new file mode 100644 index 00000000..41707bd7 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Model/PickeHTTPResponse.swift @@ -0,0 +1,17 @@ +// +// PickeHTTPResponse.swift +// PickeNetworkInterface +// + +import Foundation + +/// 상태 코드와 원시 바디를 호출부가 직접 해석해야 할 때 쓰는 응답 래퍼(디코딩 전). +public struct PickeHTTPResponse: Sendable, Equatable { + public let statusCode: Int + public let data: Data + + public init(statusCode: Int, data: Data) { + self.statusCode = statusCode + self.data = data + } +} diff --git a/Projects/Core/PickeNetwork/Interface/Model/PickeMultipartPart.swift b/Projects/Core/PickeNetwork/Interface/Model/PickeMultipartPart.swift new file mode 100644 index 00000000..6c813702 --- /dev/null +++ b/Projects/Core/PickeNetwork/Interface/Model/PickeMultipartPart.swift @@ -0,0 +1,38 @@ +// +// PickeMultipartPart.swift +// PickeNetworkInterface +// + +import Foundation + +/// 멀티파트 바디의 한 조각. (파일 또는 폼 필드) +public struct PickeMultipartPart: Sendable { + /// 파트 바이트의 출처 + public enum Source: Sendable { + /// 메모리 바이트 + case data(Data) + /// 파일 URL + case file(URL) + } + + /// 폼 필드 이름 + public let name: String + /// 파트 바이트 + public let source: Source + /// 파일 이름 (파일 파트일 때) + public let fileName: String? + /// MIME 타입 (예: "image/jpeg") + public let mimeType: String? + + public init( + name: String, + source: Source, + fileName: String? = nil, + mimeType: String? = nil + ) { + self.name = name + self.source = source + self.fileName = fileName + self.mimeType = mimeType + } +} diff --git a/Projects/Core/PickeNetwork/Project.swift b/Projects/Core/PickeNetwork/Project.swift new file mode 100644 index 00000000..953668af --- /dev/null +++ b/Projects/Core/PickeNetwork/Project.swift @@ -0,0 +1,30 @@ +// +// Project.swift +// PickeNetwork +// + +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeNetwork", + bundleId: .appBundleID(name: ".PickeNetwork"), + product: .framework, + settings: .settings(), + dependencies: [ + .core(.logger), + .SPM.alamofire, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.alamofire, + .SPM.dependencies, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Core/PickeNetwork/Sources/Auth/AuthorizingInterceptor.swift b/Projects/Core/PickeNetwork/Sources/Auth/AuthorizingInterceptor.swift new file mode 100644 index 00000000..075aebfa --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Auth/AuthorizingInterceptor.swift @@ -0,0 +1,39 @@ +// +// AuthorizingInterceptor.swift +// PickeNetwork +// + +import Foundation + +import Alamofire + +/// 공유 `AuthenticationInterceptor` 를 감싸, credential 유무로 인증 파이프라인을 우회하는 조건부 래퍼. +/// 비로그인 상태에서 인터셉터가 `missingCredential` 로 요청을 실패시키는 걸 막는다. +struct AuthorizingInterceptor: RequestInterceptor { + let base: AuthenticationInterceptor + + func adapt( + _ urlRequest: URLRequest, + for session: Session, + completion: @escaping @Sendable (Result) -> Void + ) { + guard base.credential != nil else { + completion(.success(urlRequest)) + return + } + base.adapt(urlRequest, for: session, completion: completion) + } + + func retry( + _ request: Request, + for session: Session, + dueTo error: any Error, + completion: @escaping @Sendable (RetryResult) -> Void + ) { + guard base.credential != nil else { + completion(.doNotRetry) + return + } + base.retry(request, for: session, dueTo: error, completion: completion) + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Auth/CredentialUpdater.swift b/Projects/Core/PickeNetwork/Sources/Auth/CredentialUpdater.swift new file mode 100644 index 00000000..b81ef019 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Auth/CredentialUpdater.swift @@ -0,0 +1,23 @@ +// +// CredentialUpdater.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// `AuthenticationInterceptor` 의 credential 을 교체하는 `CredentialUpdating` 구현. +/// 인터셉터 내부가 스레드 안전(@Protected)하므로 그대로 위임한다. +final class CredentialUpdater: CredentialUpdating, @unchecked Sendable { + private let interceptor: AuthenticationInterceptor + + init(interceptor: AuthenticationInterceptor) { + self.interceptor = interceptor + } + + func update(_ credential: PickeCredential?) { + interceptor.credential = credential + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Auth/PickeAuthenticator.swift b/Projects/Core/PickeNetwork/Sources/Auth/PickeAuthenticator.swift new file mode 100644 index 00000000..5605aa42 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Auth/PickeAuthenticator.swift @@ -0,0 +1,59 @@ +// +// PickeAuthenticator.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// Alamofire `Authenticator` 구현. 토큰 주입 / refresh / 401 판정을 담당한다. +/// refresh 동시요청 중복 방지(single-flight)는 `AuthenticationInterceptor` 가 처리한다. +final class PickeAuthenticator: Authenticator { + typealias Credential = PickeCredential + + private let refresher: any TokenRefreshing + private let store: any CredentialStore + + init(refresher: any TokenRefreshing, store: any CredentialStore) { + self.refresher = refresher + self.store = store + } + + /// 요청에 Bearer 토큰 주입. + func apply(_ credential: PickeCredential, to urlRequest: inout URLRequest) { + urlRequest.headers.add(.authorization(bearerToken: credential.accessToken)) + } + + /// 만료 / 401 시 새 토큰 발급. async refresher 를 completion 으로 연결한다. + func refresh( + _ credential: PickeCredential, + for _: Session, + completion: @escaping @Sendable (Result) -> Void + ) { + Task { + do { + let renewed = try await refresher.refresh(credential) + store.save(renewed) // refresh 된 토큰 영속화 + completion(.success(renewed)) + } catch { + completion(.failure(error)) + } + } + } + + /// 401 이면 인증 에러로 간주 → refresh 트리거. + func didRequest( + _: URLRequest, + with response: HTTPURLResponse, + failDueToAuthenticationError _: any Error + ) -> Bool { + response.statusCode == 401 + } + + /// 요청의 Authorization 헤더가 현재 credential 토큰과 일치하는지. + func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: PickeCredential) -> Bool { + urlRequest.headers["Authorization"] == HTTPHeader.authorization(bearerToken: credential.accessToken).value + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Client/NetworkClient.swift b/Projects/Core/PickeNetwork/Sources/Client/NetworkClient.swift new file mode 100644 index 00000000..4946d360 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Client/NetworkClient.swift @@ -0,0 +1,285 @@ +// +// NetworkClient.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// Alamofire 기반 `PickeNetworkClient` 구현. +/// +/// 서버는 성공/실패를 HTTP status 로 알리고, 바디는 공통 봉투(`{ statusCode, data, error }`)에 담아 준다. +/// 봉투 해석은 이 클라이언트가 전담한다 — 호출부는 페이로드 DTO 만 받고, `error` 는 `ResponseError` 로 받는다. +final class NetworkClient: PickeNetworkClient { + /// 바디가 비어도 성공으로 볼 status. (201 Created / 202 Accepted / 204 No Content 등) + private static let emptyResponseCodes: Set = [200, 201, 202, 204, 205] + + /// 조립된 Alamofire 세션 + private let session: Session + /// 요청별 인증 조립용 공유 인터셉터. nil 이면 인증 파이프라인 없는 클라이언트(plain). + private let authorizing: AuthorizingInterceptor? + + init( + session: Session, + authorizing: AuthorizingInterceptor? = nil + ) { + self.session = session + self.authorizing = authorizing + } +} + +// MARK: - PickeRequestClient (일반 요청) + +extension NetworkClient: PickeRequestClient { + func send( + _ request: R, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + let dataRequest = try makeDataRequest(request) + return try await handle(dataRequest, as: T.self) + } + + func send(_ request: R) async throws(PickeNetworkError) -> R.Response { + let dataRequest = try makeDataRequest(request) + return try await handle(dataRequest, as: R.Response.self) + } + + func sendResponse(_ request: R) async throws(PickeNetworkError) -> PickeHTTPResponse { + let dataRequest = try makeDataRequest(request) + let startedAt = Date() + let response = await dataRequest.serializingData(emptyResponseCodes: Self.emptyResponseCodes).response + Self.recordTelemetry(response: response.response, request: response.request, startedAt: startedAt, isSuccess: response.error == nil) + if let error = response.error, response.response == nil { + throw Self.mapFailure(error, data: response.data, status: -1) + } + return PickeHTTPResponse( + statusCode: response.response?.statusCode ?? -1, + data: response.data ?? Data() + ) + } + + /// 요청을 URLRequest 로 만들고 인증·재시도 인터셉터를 조립한다. 두 send 오버로드가 공유한다. + private func makeDataRequest(_ request: R) throws(PickeNetworkError) -> DataRequest { + // 1) PickeDataRequest → URLRequest (헤더 / 타임아웃 / 파라미터 인코딩) + let urlRequest: URLRequest + do { + urlRequest = try request.asURLRequest() + } catch let error as PickeNetworkError { + throw error + } catch { + throw .request(.encodingFailed(error)) + } + + // 2) 인증(요청의 authorization 정책)과 재시도를 요청별로 조립한다. + return session.request(urlRequest, interceptor: interceptor(for: request)) + } +} + +// MARK: - PickeUploadClient (멀티파트 업로드) + +extension NetworkClient: PickeUploadClient { + func upload(_ request: R) async throws(PickeNetworkError) -> R.Response { + let url = try request.url() + // escaping 클로저에는 Sendable 값만 캡처한다. + let parts = request.parts + let timeout = request.timeoutInterval + + // UploadRequest 는 DataRequest 의 하위 → 응답 처리(handle)를 그대로 공유한다. + let uploadRequest = session.upload( + multipartFormData: { form in parts.forEach { Self.append($0, to: form) } }, + to: url, + method: request.method, + headers: request.headers, + interceptor: interceptor(for: request), + requestModifier: { urlRequest in + if let timeout { urlRequest.timeoutInterval = timeout } + } + ) + return try await handle(uploadRequest, as: R.Response.self) + } +} + +// MARK: - PickeFileUploadClient (presigned 원본 바이트 PUT) + +extension NetworkClient: PickeFileUploadClient { + func upload(_ request: some PickeFileUploadRequest) async throws(PickeNetworkError) { + var urlRequest = URLRequest(url: request.uploadURL) + urlRequest.method = .put + urlRequest.httpBody = request.body + urlRequest.setValue(request.contentType, forHTTPHeaderField: APIHeader.contentType) + if let timeout = request.timeoutInterval { + urlRequest.timeoutInterval = timeout + } + + let response = await session.request(urlRequest, interceptor: request.retryPolicy) + .validate() + .serializingData(emptyResponseCodes: Self.emptyResponseCodes) + .response + + if case let .failure(afError) = response.result { + throw Self.mapFailure(afError, data: response.data, status: response.response?.statusCode ?? -1) + } + } +} + +// MARK: - 요청별 인터셉터 + +private extension NetworkClient { + /// 요청의 인증 정책에 따라 인터셉터를 조립한다. + func interceptor(for request: some PickeEndpoint) -> any RequestInterceptor { + guard let authorizing, request.authorization == .automatic else { + return request.retryPolicy + } + return Interceptor(interceptors: [authorizing, request.retryPolicy]) + } +} + +// MARK: - Response Handling (send / upload 공유) + +private extension NetworkClient { + /// 응답을 `T` 로 디코딩한다. 실패 판정은 `validate()` 의 HTTP status 검증이 맡고, + /// 실패 바디에 담긴 `{ code, message }` 는 `ResponseError` 로 살려 낸다. + func handle( + _ dataRequest: DataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + let startedAt = Date() + let response = await dataRequest + .validate() + .serializingDecodable( + ResponseEnvelope.self, + emptyResponseCodes: Self.emptyResponseCodes + ) + .response + Self.recordTelemetry( + response: response.response, + request: response.request, + startedAt: startedAt, + isSuccess: response.error == nil + ) + + switch response.result { + case let .success(envelope): + return try Self.unwrap(envelope, status: response.response?.statusCode ?? -1) + case let .failure(afError): + throw Self.mapFailure(afError, data: response.data, status: response.response?.statusCode ?? -1) + } + } + + /// 봉투의 `error` 를 에러로 승격시키고, 없으면 페이로드를 꺼낸다. + /// HTTP 2xx 여도 `error` 가 채워져 있으면 실패로 본다 — 서버가 200 에 실패를 담아 보내는 경로가 있다. + static func unwrap( + _ envelope: ResponseEnvelope, + status: Int + ) throws(PickeNetworkError) -> T { + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? status, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + // 페이로드를 기대하지 않는 요청(`PickeEmptyResponse`)은 빈 바디도 성공이다. + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } + + static func recordTelemetry( + response: HTTPURLResponse?, + request: URLRequest?, + startedAt: Date, + isSuccess: Bool + ) { + NetworkTelemetry.shared.record( + NetworkTelemetryEvent( + source: "alamofire", + method: request?.httpMethod ?? "UNKNOWN", + url: request?.url, + statusCode: response?.statusCode, + duration: Date().timeIntervalSince(startedAt), + isSuccess: isSuccess + ) + ) + } +} + +// MARK: - Multipart 매핑 + +private extension NetworkClient { + /// `PickeMultipartPart` → Alamofire `MultipartFormData` 한 파트 추가. + static func append(_ part: PickeMultipartPart, to form: MultipartFormData) { + switch part.source { + case let .data(data): + form.append(data, withName: part.name, fileName: part.fileName, mimeType: part.mimeType) + case let .file(url): + if let fileName = part.fileName, let mimeType = part.mimeType { + form.append(url, withName: part.name, fileName: fileName, mimeType: mimeType) + } else { + form.append(url, withName: part.name) + } + } + } +} + +// MARK: - Error 매핑 + +private extension NetworkClient { + /// `AFError` 를 파이프라인 단계별 `PickeNetworkError` 로 좁힌다. + static func mapFailure(_ error: AFError, data: Data?, status: Int) -> PickeNetworkError { + switch error { + // 상태코드 검증 실패 — 서버가 내려준 에러. 바디의 code/message 를 살린다. + case .responseValidationFailed(.unacceptableStatusCode): + let body = data.flatMap { try? JSONDecoder().decode(ErrorBody.self, from: $0) } + return .response( + ResponseError(httpStatus: status, code: body?.resolvedCode, message: body?.resolvedMessage) + ) + + // 응답 디코딩 실패 — PickeEventMonitor 가 원시 바디와 함께 로깅한다. + case let .responseSerializationFailed(.decodingFailed(decodingError)): + return .decoding(.failed(decodingError)) + + // 값을 기대했는데 바디가 비어 있음 + case .responseSerializationFailed(.inputDataNilOrZeroLength), + .responseSerializationFailed(.invalidEmptyResponse): + return .decoding(.dataMissing) + + // 서버가 JSON 아닌 응답을 줌 (콘텐츠 타입 불일치) + case .responseValidationFailed(.unacceptableContentType), + .responseValidationFailed(.missingContentType): + return .decoding(.failed(error)) + + // 전송 실패(오프라인 / 타임아웃 / 취소) 및 그 외(요청 적응·재시도 실패, TLS 등) + default: + return .transport(mapTransportError(error)) + } + } + + /// `AFError` → `TransportError` (연결 없음 / 타임아웃 / 취소 / 기타). + static func mapTransportError(_ error: AFError) -> TransportError { + if case .explicitlyCancelled = error { + return .cancelled + } + guard let urlError = error.underlyingError as? URLError else { + return .unknown(error) + } + switch urlError.code { + case .notConnectedToInternet, .dataNotAllowed, .networkConnectionLost: + return .notConnected + case .timedOut: + return .timedOut + case .cancelled: + return .cancelled + default: + return .unknown(error) + } + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Endpoint/PickeDataRequest+URLRequest.swift b/Projects/Core/PickeNetwork/Sources/Endpoint/PickeDataRequest+URLRequest.swift new file mode 100644 index 00000000..a42ef9c2 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Endpoint/PickeDataRequest+URLRequest.swift @@ -0,0 +1,46 @@ +// +// PickeDataRequest+URLRequest.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +extension PickeDataRequest { + func asURLRequest() throws -> URLRequest { + var urlRequest = try URLRequest( + url: url(), + method: method, + headers: headers + ) + + if let timeoutInterval { + urlRequest.timeoutInterval = timeoutInterval + } + + guard let parameters else { return urlRequest } + return try encode(parameters, using: resolvedEncoder, into: urlRequest) + } + + /// 요청이 인코더를 오버라이드하면 그대로, 아니면 method 기준(GET/DELETE 쿼리스트링 · 그 외 JSON 바디). + private var resolvedEncoder: ParameterEncoder { + if let parameterEncoder { + return parameterEncoder + } + return (method == .get || method == .delete) + ? URLEncodedFormParameterEncoder.default + : JSONParameterEncoder.default + } + + /// 파라미터 인코딩. 호출부가 `any Encodable` 을 넘기면 이 제네릭 시점에 열린다(implicit opening). + /// 인코딩 실패는 raw 로 던지고, 호출부(NetworkClient)가 `.request(.encodingFailed)` 로 감싼다. + private func encode( + _ parameters: some Encodable & Sendable, + using encoder: ParameterEncoder, + into request: URLRequest + ) throws -> URLRequest { + try encoder.encode(parameters, into: request) + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Endpoint/PickeEndpoint+RetryPolicy.swift b/Projects/Core/PickeNetwork/Sources/Endpoint/PickeEndpoint+RetryPolicy.swift new file mode 100644 index 00000000..d32025d8 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Endpoint/PickeEndpoint+RetryPolicy.swift @@ -0,0 +1,32 @@ +// +// PickeEndpoint+RetryPolicy.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// 429(Too Many Requests)까지 포함한 재시도 대상 status 집합. +private let retryableStatusCodes = RetryPolicy.defaultRetryableHTTPStatusCodes.union([429]) + +extension PickeEndpoint { + /// 요청별 재시도 정책. 횟수는 `maxRetryAttempts` 를 단일 출처로 쓴다. (send / upload 공유) + var retryPolicy: RetryPolicy { + RetryPolicy( + retryLimit: UInt(max(0, maxRetryAttempts)), + retryableHTTPStatusCodes: retryableStatusCodes + ) + } +} + +extension PickeFileUploadRequest { + /// presigned 업로드는 엔드포인트 메타가 없어 기본 횟수를 쓴다. + var retryPolicy: RetryPolicy { + RetryPolicy( + retryLimit: 3, + retryableHTTPStatusCodes: retryableStatusCodes + ) + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Exported/PickeNetworkExported.swift b/Projects/Core/PickeNetwork/Sources/Exported/PickeNetworkExported.swift new file mode 100644 index 00000000..26bc958b --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Exported/PickeNetworkExported.swift @@ -0,0 +1,8 @@ +// +// PickeNetworkExported.swift +// PickeNetwork +// + +// MARK: - 계약과 구현을 한번에 노출 + +@_exported import PickeNetworkInterface diff --git a/Projects/Core/PickeNetwork/Sources/Factory/NetworkClientFactory.swift b/Projects/Core/PickeNetwork/Sources/Factory/NetworkClientFactory.swift new file mode 100644 index 00000000..496b9093 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Factory/NetworkClientFactory.swift @@ -0,0 +1,40 @@ +// +// NetworkClientFactory.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// `PickeNetworkClient` 조립 진입점. 호출부는 이 팩토리만 알면 된다. +/// baseURL 은 엔드포인트의 `domain` 이 들고 있으므로 팩토리는 인증 조립만 책임진다. +public enum NetworkClientFactory { + /// 인증 없는 기본 클라이언트. (로그인 / 토큰 재발급처럼 토큰이 아직 없는 요청) + public static func plain(eventMonitors: [any EventMonitor] = []) -> any PickeNetworkClient { + NetworkClient(session: SessionFactory.plain(eventMonitors: eventMonitors)) + } + + /// 단일 클라이언트 — 요청의 `authorization` 정책에 따라 요청 시점에 인증 부착을 판단한다. + /// (`.automatic`: 로그인 상태면 토큰 부착, `.none`: 인증 파이프라인 우회) + /// + /// 반환된 `credentials` 로 로그인 / 로그아웃 시점의 토큰 교체를 세션에 반영해야 한다. + public static func unified( + store: any CredentialStore, + refresher: any TokenRefreshing, + eventMonitors: [any EventMonitor] = [] + ) -> (client: any PickeNetworkClient, credentials: any CredentialUpdating) { + let (authorizing, credentials) = SessionFactory.authorization( + store: store, + refresher: refresher + ) + return ( + NetworkClient( + session: SessionFactory.plain(eventMonitors: eventMonitors), + authorizing: authorizing + ), + credentials + ) + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Factory/SessionFactory.swift b/Projects/Core/PickeNetwork/Sources/Factory/SessionFactory.swift new file mode 100644 index 00000000..3c4c6c58 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Factory/SessionFactory.swift @@ -0,0 +1,64 @@ +// +// SessionFactory.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +enum SessionFactory { + static func plain(eventMonitors: [any EventMonitor] = []) -> Session { + Session( + configuration: configuration, + eventMonitors: [PickeEventMonitor()] + eventMonitors + ) + } + + /// 요청별 인증 조립용 공유 인터셉터 묶음. + /// `AuthenticationInterceptor` 인스턴스가 하나여야 refresh single-flight 가 보장된다 — + /// authorizing(요청 조립)과 credentials(로그인 / 로그아웃 교체)가 같은 인스턴스를 본다. + static func authorization( + store: any CredentialStore, + refresher: any TokenRefreshing + ) -> (authorizing: AuthorizingInterceptor, credentials: any CredentialUpdating) { + let interceptor = AuthenticationInterceptor( + authenticator: PickeAuthenticator(refresher: refresher, store: store), + credential: store.load() + ) + return ( + AuthorizingInterceptor(base: interceptor), + CredentialUpdater(interceptor: interceptor) + ) + } + + /// 성능 최적화된 URLSession 설정(커넥션 풀 / 캐시 / keep-alive) + 공통 정적 헤더. + private static var configuration: URLSessionConfiguration { + 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 + + var headers = DefaultHeaders.headers.dictionary + headers["Connection"] = "keep-alive" + headers["Keep-Alive"] = "timeout=120, max=1000" + configuration.httpAdditionalHeaders = headers + + return configuration + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Header/DefaultHeaders.swift b/Projects/Core/PickeNetwork/Sources/Header/DefaultHeaders.swift new file mode 100644 index 00000000..46b1c9c3 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Header/DefaultHeaders.swift @@ -0,0 +1,20 @@ +// +// DefaultHeaders.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// 모든 요청에 공통으로 박히는 정적 헤더. +/// 요청별 헤더는 `PickeEndpoint.headers`, 토큰은 `PickeAuthenticator` 가 붙인다. +/// Content-Type 은 인코더 / 멀티파트가 요청마다 정하므로 여기서 고정하지 않는다. +enum DefaultHeaders { + static var headers: HTTPHeaders { + var headers = HTTPHeaders.default + headers.add(name: "Accept", value: APIHeaderManger.contentType) + return headers + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Model/ErrorBody.swift b/Projects/Core/PickeNetwork/Sources/Model/ErrorBody.swift new file mode 100644 index 00000000..5a49b383 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Model/ErrorBody.swift @@ -0,0 +1,22 @@ +// +// ErrorBody.swift +// PickeNetwork +// + +import Foundation + +/// 서버가 실패 응답(4xx/5xx)에 담아 주는 바디. +/// 공통 봉투의 `error` 를 우선 읽고, 봉투 없이 `{ code, message }` 만 오는 응답도 함께 살린다. +struct ErrorBody: Decodable { + struct Payload: Decodable { + let code: String? + let message: String? + } + + let error: Payload? + let code: String? + let message: String? + + var resolvedCode: String? { error?.code ?? code } + var resolvedMessage: String? { error?.message ?? message } +} diff --git a/Projects/Core/PickeNetwork/Sources/Model/ResponseEnvelope.swift b/Projects/Core/PickeNetwork/Sources/Model/ResponseEnvelope.swift new file mode 100644 index 00000000..69dbf4a7 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Model/ResponseEnvelope.swift @@ -0,0 +1,32 @@ +// +// ResponseEnvelope.swift +// PickeNetwork +// + +import Foundation + +import Alamofire +import PickeNetworkInterface + +/// 서버 공통 응답 봉투. +/// ```json +/// { "statusCode": 200, "data": { ... }, "error": { "code": "...", "message": "..." } } +/// ``` +/// 봉투 해석은 클라이언트가 전담한다 — 호출부(Repository)는 페이로드만 받는다. +struct ResponseEnvelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? +} + +extension ResponseEnvelope: EmptyResponse where Payload == PickeEmptyResponse { + /// 204 처럼 바디가 없는 성공 응답은 빈 페이로드로 채운다. + static func emptyValue() -> ResponseEnvelope { + ResponseEnvelope(statusCode: nil, data: PickeEmptyResponse(), error: nil) + } +} diff --git a/Projects/Core/PickeNetwork/Sources/Monitor/NetworkTelemetry.swift b/Projects/Core/PickeNetwork/Sources/Monitor/NetworkTelemetry.swift new file mode 100644 index 00000000..09554998 --- /dev/null +++ b/Projects/Core/PickeNetwork/Sources/Monitor/NetworkTelemetry.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct NetworkTelemetryEvent: Sendable { + public let source: String + public let method: String + public let host: String + public let path: String + public let statusCode: Int? + public let durationMilliseconds: Int + public let isSuccess: Bool + + public init( + source: String, + method: String, + url: URL?, + statusCode: Int?, + duration: TimeInterval, + isSuccess: Bool + ) { + self.source = source + self.method = method + host = url?.host ?? "" + path = url?.path ?? "" + self.statusCode = statusCode + durationMilliseconds = max(0, Int(duration * 1000)) + self.isSuccess = isSuccess + } +} + +public final class NetworkTelemetry: @unchecked Sendable { + public static let shared = NetworkTelemetry() + + private let lock = NSLock() + private var handler: (@Sendable (NetworkTelemetryEvent) -> Void)? + + private init() {} + + public func configure( + handler: @escaping @Sendable (NetworkTelemetryEvent) -> Void + ) { + lock.withLock { + self.handler = handler + } + } + + public func record(_ event: NetworkTelemetryEvent) { + let currentHandler = lock.withLock { handler } + currentHandler?(event) + } +} diff --git a/Projects/Data/Repository/Sources/Network/PickeEventMonitor.swift b/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift similarity index 57% rename from Projects/Data/Repository/Sources/Network/PickeEventMonitor.swift rename to Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift index 925291e8..02f4cdbc 100644 --- a/Projects/Data/Repository/Sources/Network/PickeEventMonitor.swift +++ b/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift @@ -1,11 +1,11 @@ // // PickeEventMonitor.swift -// Repository +// PickeNetwork // import Alamofire +import PickeCoreLogger import Foundation -import LogMacro struct PickeEventMonitor: EventMonitor { let queue = DispatchQueue(label: "store.picke.network.logger") @@ -16,7 +16,7 @@ struct PickeEventMonitor: EventMonitor { // MARK: 요청 생성 실패 (URL/파라미터 인코딩 등 — 전송 전) func request(_: Request, didFailToCreateURLRequestWithError error: AFError) { - Log.error("❌ [요청 생성 실패] \(error.localizedDescription)") + PickeLogger.error("❌ [요청 생성 실패] \(error.localizedDescription)", category: .network) } // MARK: 요청+응답 (응답 시점에 한 덩어리로 묶어서 로깅) @@ -44,9 +44,15 @@ struct PickeEventMonitor: EventMonitor { // ── 응답 ── if let error = response.error { - lines.append(" · Error\n \(error.localizedDescription)") - if let underlying = error.underlyingError { - lines.append(" underlying: \(underlying.localizedDescription)") + if case let .responseSerializationFailed(.decodingFailed(underlying)) = error, + let decodingError = underlying as? DecodingError + { + lines.append(describe(decodingError)) + } else { + lines.append(" · Error\n \(error.localizedDescription)") + if let underlying = error.underlyingError { + lines.append(" underlying: \(underlying.localizedDescription)") + } } } if let data = response.data, !data.isEmpty { @@ -55,14 +61,57 @@ struct PickeEventMonitor: EventMonitor { let message = lines.joined(separator: "\n") if isFailure { - Log.error(message) + PickeLogger.error(message, category: .network) } else { - Log.debug(message) + PickeLogger.debug(message, category: .network) } } } private extension PickeEventMonitor { + /// `DecodingError` 를 "종류 · 위치 · 상세" 세 줄로 정리한다. + /// 원시 Response Body 와 분리해 무엇이 왜 틀렸는지만 짚는다. + func describe(_ error: DecodingError) -> String { + func location(_ context: DecodingError.Context, key: String? = nil) -> String { + var parts = context.codingPath.map(\.stringValue) + if let key { parts.append(key) } + return parts.isEmpty ? "(root)" : parts.joined(separator: ".") + } + + let kind: String + let place: String + let detail: String + switch error { + case let .keyNotFound(key, context): + kind = "keyNotFound" + place = location(context, key: key.stringValue) + detail = "'\(key.stringValue)' 키가 응답에 없음" + case let .typeMismatch(type, context): + kind = "typeMismatch" + place = location(context) + detail = "기대 타입 \(type)" + case let .valueNotFound(type, context): + kind = "valueNotFound" + place = location(context) + detail = "\(type) 필수인데 null" + case let .dataCorrupted(context): + kind = "dataCorrupted" + place = location(context) + detail = context.debugDescription + @unknown default: + kind = "unknown" + place = "-" + detail = String(describing: error) + } + + return [ + " · Decoding Error", + " 종류: \(kind)", + " 위치: \(place)", + " 상세: \(detail)", + ].joined(separator: "\n") + } + /// 헤더를 정렬해 한 줄씩 들여쓰기. func format(_ headers: [String: String]) -> String { headers diff --git a/Projects/Core/PickeNetwork/Tests/Sources/AuthorizingInterceptorTests.swift b/Projects/Core/PickeNetwork/Tests/Sources/AuthorizingInterceptorTests.swift new file mode 100644 index 00000000..3f73aafa --- /dev/null +++ b/Projects/Core/PickeNetwork/Tests/Sources/AuthorizingInterceptorTests.swift @@ -0,0 +1,76 @@ +// +// AuthorizingInterceptorTests.swift +// PickeNetworkTests +// + +import Foundation +import Testing + +@testable import PickeNetwork + +import Alamofire + +@Suite("AuthorizingInterceptor") +struct AuthorizingInterceptorTests { + @Test("credential 이 있으면 Authorization Bearer 헤더를 주입한다") + func automaticAuthorizationAddsBearerToken() async throws { + let interceptor = makeAuthorizingInterceptor( + credential: PickeCredential(accessToken: "access-token", refreshToken: "refresh-token") + ) + let url = try #require(URL(string: "https://picke.test/protected")) + let request = URLRequest(url: url) + + let adapted = try await interceptor.adapt(request, for: Session()) + + #expect(adapted.value(forHTTPHeaderField: APIHeader.accessToken) == "Bearer access-token") + } + + @Test("credential 이 없으면 Authorization 헤더를 주입하지 않는다") + func missingCredentialSkipsBearerToken() async throws { + let interceptor = makeAuthorizingInterceptor(credential: nil) + let url = try #require(URL(string: "https://picke.test/public")) + let request = URLRequest(url: url) + + let adapted = try await interceptor.adapt(request, for: Session()) + + #expect(adapted.value(forHTTPHeaderField: APIHeader.accessToken) == nil) + } + + private func makeAuthorizingInterceptor(credential: PickeCredential?) -> AuthorizingInterceptor { + AuthorizingInterceptor( + base: AuthenticationInterceptor( + authenticator: PickeAuthenticator(refresher: StubTokenRefresher(), store: StubCredentialStore()), + credential: credential + ) + ) + } +} + +private struct StubCredentialStore: CredentialStore { + func load() -> PickeCredential? { + nil + } + + func save(_: PickeCredential) {} + + func clear() {} +} + +private struct StubTokenRefresher: TokenRefreshing { + func refresh(_ current: PickeCredential) async throws(PickeNetworkError) -> PickeCredential { + current + } +} + +private extension AuthorizingInterceptor { + func adapt( + _ request: URLRequest, + for session: Session + ) async throws -> URLRequest { + try await withCheckedThrowingContinuation { continuation in + adapt(request, for: session) { result in + continuation.resume(with: result) + } + } + } +} diff --git a/Projects/Core/PickeNetwork/Tests/Sources/PickeCredentialTests.swift b/Projects/Core/PickeNetwork/Tests/Sources/PickeCredentialTests.swift new file mode 100644 index 00000000..c83e270c --- /dev/null +++ b/Projects/Core/PickeNetwork/Tests/Sources/PickeCredentialTests.swift @@ -0,0 +1,48 @@ +// +// PickeCredentialTests.swift +// PickeNetworkTests +// +// 토큰 사전 갱신 판정을 검증한다. 401 을 받기 전에 미리 갱신하는 근거가 되는 값이다. +// + +import Foundation +import Testing + +import PickeNetworkInterface + +@Suite("PickeCredential — 갱신 필요 판정") +struct PickeCredentialTests { + private func credential(expiresIn seconds: TimeInterval?, leeway: TimeInterval = 300) -> PickeCredential { + PickeCredential( + accessToken: "access", + refreshToken: "refresh", + expiresAt: seconds.map { Date().addingTimeInterval($0) }, + refreshLeeway: leeway + ) + } + + @Test("만료 시각이 없으면 갱신하지 않는다 — 401 을 받고 나서야 갱신한다") + func noExpiryMeansNoRefresh() { + #expect(credential(expiresIn: nil).requiresRefresh == false) + } + + @Test("여유 시간보다 많이 남았으면 갱신하지 않는다") + func farFromExpiryDoesNotRefresh() { + #expect(credential(expiresIn: 600, leeway: 300).requiresRefresh == false) + } + + @Test("여유 시간 안으로 들어오면 만료 전이라도 갱신한다") + func withinLeewayRefreshes() { + #expect(credential(expiresIn: 120, leeway: 300).requiresRefresh == true) + } + + @Test("이미 만료됐으면 갱신한다") + func expiredRefreshes() { + #expect(credential(expiresIn: -60).requiresRefresh == true) + } + + @Test("여유 시간이 0이면 만료 시각까지는 갱신하지 않는다") + func zeroLeewayWaitsUntilExpiry() { + #expect(credential(expiresIn: 10, leeway: 0).requiresRefresh == false) + } +} diff --git a/Projects/Core/PickeNetwork/Tests/Sources/PickeNetworkTests.swift b/Projects/Core/PickeNetwork/Tests/Sources/PickeNetworkTests.swift new file mode 100644 index 00000000..75c07e2e --- /dev/null +++ b/Projects/Core/PickeNetwork/Tests/Sources/PickeNetworkTests.swift @@ -0,0 +1,16 @@ +// +// PickeNetworkTests.swift +// PickeNetworkTests +// + +import Testing + +@testable import PickeNetwork + +@Suite("PickeNetwork") +struct PickeNetworkTests { + @Test("계약 헤더 상수가 재수출된다") + func reexportsInterface() { + #expect(APIHeader.contentType == "Content-Type") + } +} diff --git a/Projects/Core/PickeNetwork/Tests/Sources/Support/URLProtocolStub.swift b/Projects/Core/PickeNetwork/Tests/Sources/Support/URLProtocolStub.swift new file mode 100644 index 00000000..05e12eb6 --- /dev/null +++ b/Projects/Core/PickeNetwork/Tests/Sources/Support/URLProtocolStub.swift @@ -0,0 +1,136 @@ +// +// URLProtocolStub.swift +// PickeNetworkTests +// + +import Foundation + +@testable import PickeNetwork + +import Alamofire + +/// 실제 통신 없이 `NetworkClient` 의 요청과 응답을 검증하는 URLProtocol 스텁. +/// 정적 상태를 사용하므로 이 스텁을 쓰는 테스트 스위트는 직렬로 실행해야 한다. +final class URLProtocolStub: URLProtocol { + private struct Stub { + let statusCode: Int + let body: Data + let headerFields: [String: String] + let error: Error? + } + + private nonisolated(unsafe) static var stubs: [Stub] = [] + private nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] + private static let lock = NSLock() + + static func set( + statusCode: Int, + body: Data = Data(), + headerFields: [String: String] = ["Content-Type": "application/json"] + ) { + lock.withLock { + stubs = [Stub(statusCode: statusCode, body: body, headerFields: headerFields, error: nil)] + } + } + + static func setSequence(_ responses: [(statusCode: Int, body: Data)]) { + lock.withLock { + stubs = responses.map { + Stub( + statusCode: $0.statusCode, + body: $0.body, + headerFields: ["Content-Type": "application/json"], + error: nil + ) + } + } + } + + static func setError(_ error: Error) { + lock.withLock { + stubs = [Stub(statusCode: 0, body: Data(), headerFields: [:], error: error)] + } + } + + static func reset() { + lock.withLock { + stubs = [] + capturedRequests = [] + } + } + + static var recordedRequests: [URLRequest] { + lock.withLock { capturedRequests } + } + + /// baseURL 은 엔드포인트의 `domain` 이 들고 있으므로 클라이언트는 세션만 갈아끼운다. + static func makeClient(authorizing: AuthorizingInterceptor? = nil) -> NetworkClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return NetworkClient( + session: Session(configuration: configuration), + authorizing: authorizing + ) + } + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let current = Self.lock.withLock { () -> Stub? in + var capturedRequest = request + if capturedRequest.httpBody == nil, let bodyStream = capturedRequest.httpBodyStream { + capturedRequest.httpBody = Self.readData(from: bodyStream) + } + Self.capturedRequests.append(capturedRequest) + guard !Self.stubs.isEmpty else { return nil } + if Self.stubs.count == 1 { + return Self.stubs[0] + } + return Self.stubs.removeFirst() + } + + guard let current else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + + if let error = current.error { + client?.urlProtocol(self, didFailWithError: error) + return + } + + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://picke.test")!, + statusCode: current.statusCode, + httpVersion: "HTTP/1.1", + headerFields: current.headerFields + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if !current.body.isEmpty { + client?.urlProtocol(self, didLoad: current.body) + } + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func readData(from stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } +} diff --git a/Projects/Core/PickeStorage/Interface/Database/AppDatabaseDependency.swift b/Projects/Core/PickeStorage/Interface/Database/AppDatabaseDependency.swift new file mode 100644 index 00000000..df6a2f41 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Database/AppDatabaseDependency.swift @@ -0,0 +1,18 @@ +// +// AppDatabaseDependency.swift +// PickeStorageInterface +// + +import Dependencies +import SQLiteData + +public enum AppDatabaseDependency: TestDependencyKey { + public static let testValue: (any DatabaseWriter)? = nil +} + +public extension DependencyValues { + var appDatabase: (any DatabaseWriter)? { + get { self[AppDatabaseDependency.self] } + set { self[AppDatabaseDependency.self] = newValue } + } +} diff --git a/Projects/Core/PickeStorage/Interface/Device/DeviceTokenStorage.swift b/Projects/Core/PickeStorage/Interface/Device/DeviceTokenStorage.swift new file mode 100644 index 00000000..c54f66ad --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Device/DeviceTokenStorage.swift @@ -0,0 +1,20 @@ +// +// DeviceTokenStorage.swift +// PickeStorageInterface +// + +import Sharing + +/// APNs 디바이스 토큰 보관소. PickeStorage 영속 경계와 테스트 메모리 경계를 함께 사용한다. +public enum DeviceTokenStorage { + public static var token: String? { + get { + @Shared(.deviceToken) var token: String? + return token + } + set { + @Shared(.deviceToken) var token: String? + $token.withLock { $0 = newValue } + } + } +} diff --git a/Projects/Core/PickeStorage/Interface/KeyValue/KeyValueStorage.swift b/Projects/Core/PickeStorage/Interface/KeyValue/KeyValueStorage.swift new file mode 100644 index 00000000..77d4ca72 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/KeyValue/KeyValueStorage.swift @@ -0,0 +1,30 @@ +// +// KeyValueStorage.swift +// PickeStorageInterface +// + +import Dependencies + +public protocol KeyValueStorage: Sendable { + func load(_ key: KeyValueStorageKey) -> Value? + func save(_ value: Value?, for key: KeyValueStorageKey) +} + +public enum KeyValueStorageDependency: TestDependencyKey { + public static let testValue: any KeyValueStorage = UnimplementedKeyValueStorage() +} + +public extension DependencyValues { + var keyValueStorage: any KeyValueStorage { + get { self[KeyValueStorageDependency.self] } + set { self[KeyValueStorageDependency.self] = newValue } + } +} + +private struct UnimplementedKeyValueStorage: KeyValueStorage { + func load(_: KeyValueStorageKey) -> Value? { + nil + } + + func save(_: Value?, for _: KeyValueStorageKey) {} +} diff --git a/Projects/Core/PickeStorage/Interface/KeyValue/KeyValueStorageKey.swift b/Projects/Core/PickeStorage/Interface/KeyValue/KeyValueStorageKey.swift new file mode 100644 index 00000000..03e93186 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/KeyValue/KeyValueStorageKey.swift @@ -0,0 +1,12 @@ +// +// KeyValueStorageKey.swift +// PickeStorageInterface +// + +public struct KeyValueStorageKey: Hashable, Sendable { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Projects/Core/PickeStorage/Interface/KeyValue/PushStorageKey.swift b/Projects/Core/PickeStorage/Interface/KeyValue/PushStorageKey.swift new file mode 100644 index 00000000..d7a0eda5 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/KeyValue/PushStorageKey.swift @@ -0,0 +1,9 @@ +// +// PushStorageKey.swift +// PickeStorageInterface +// + +public enum PushStorageKey { + public static let deviceToken = KeyValueStorageKey("PickeDeviceToken") + public static let pendingDeeplink = KeyValueStorageKey("PickePendingDeeplink") +} diff --git a/Projects/Core/PickeStorage/Interface/Secure/SecureStorage.swift b/Projects/Core/PickeStorage/Interface/Secure/SecureStorage.swift new file mode 100644 index 00000000..e34d003b --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Secure/SecureStorage.swift @@ -0,0 +1,12 @@ +// +// SecureStorage.swift +// PickeStorageInterface +// + +/// 민감한 문자열을 보관하는 저장소 경계. +public protocol SecureStorage: Sendable { + func save(_ value: String, for key: SecureStorageKey) throws(SecureStorageError) + func load(_ key: SecureStorageKey) throws(SecureStorageError) -> String? + func remove(_ key: SecureStorageKey) throws(SecureStorageError) + func removeAll() throws(SecureStorageError) +} diff --git a/Projects/Core/PickeStorage/Interface/Secure/SecureStorageError.swift b/Projects/Core/PickeStorage/Interface/Secure/SecureStorageError.swift new file mode 100644 index 00000000..453c28b0 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Secure/SecureStorageError.swift @@ -0,0 +1,11 @@ +// +// SecureStorageError.swift +// PickeStorageInterface +// + +import Foundation + +public enum SecureStorageError: Error { + case invalidData + case unexpectedStatus(OSStatus) +} diff --git a/Projects/Core/PickeStorage/Interface/Secure/SecureStorageKey.swift b/Projects/Core/PickeStorage/Interface/Secure/SecureStorageKey.swift new file mode 100644 index 00000000..fae87262 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Secure/SecureStorageKey.swift @@ -0,0 +1,20 @@ +// +// SecureStorageKey.swift +// PickeStorageInterface +// + +public struct SecureStorageKey: Hashable, Sendable { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } +} + +public extension SecureStorageKey { + // 이전 버전이 쓰던 Keychain 계정명을 그대로 유지해 업데이트 후에도 세션이 살아있게 한다. + static let accessToken = SecureStorageKey("ACCESS_TOKEN") + static let refreshToken = SecureStorageKey("REFRESH_TOKEN") + + static let all: [SecureStorageKey] = [.accessToken, .refreshToken] +} diff --git a/Projects/Core/PickeStorage/Interface/Session/SessionCacheInvalidating.swift b/Projects/Core/PickeStorage/Interface/Session/SessionCacheInvalidating.swift new file mode 100644 index 00000000..c9b12bf9 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Session/SessionCacheInvalidating.swift @@ -0,0 +1,30 @@ +// +// SessionCacheInvalidating.swift +// PickeStorageInterface +// + +import Dependencies + +/// 세션이 끊길 때(로그아웃·탈퇴·토큰 만료) 계정에 묶인 로컬 캐시를 비우는 계약. +public protocol SessionCacheInvalidating: Sendable { + func invalidate() async +} + +/// 비울 로컬 캐시가 아직 없는 구성에서 쓰는 기본 구현. +public struct NoopSessionCacheInvalidator: SessionCacheInvalidating { + public init() {} + + public func invalidate() async {} +} + +public enum SessionCacheInvalidatorDependency: TestDependencyKey { + public static let testValue: any SessionCacheInvalidating = NoopSessionCacheInvalidator() + public static let previewValue: any SessionCacheInvalidating = NoopSessionCacheInvalidator() +} + +public extension DependencyValues { + var sessionCacheInvalidator: any SessionCacheInvalidating { + get { self[SessionCacheInvalidatorDependency.self] } + set { self[SessionCacheInvalidatorDependency.self] = newValue } + } +} diff --git a/Projects/Core/PickeStorage/Interface/Shared/DeviceTokenSharedKey.swift b/Projects/Core/PickeStorage/Interface/Shared/DeviceTokenSharedKey.swift new file mode 100644 index 00000000..91abed64 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Shared/DeviceTokenSharedKey.swift @@ -0,0 +1,23 @@ +import Foundation + +import Dependencies +import Sharing + +public extension SharedReaderKey where Self == PersistentSharedKey.Default { + /// APNs 디바이스 토큰. 기존 UserDefaults key 를 첫 조회 때 이관한다. + static var deviceToken: Self { + Self[ + .persistent( + PushStorageKey.deviceToken.rawValue, + encode: { try JSONEncoder().encode($0) }, + decode: { try JSONDecoder().decode(String?.self, from: $0) }, + legacyData: { + @Dependency(\.keyValueStorage) var keyValueStorage + guard let token: String = keyValueStorage.load(PushStorageKey.deviceToken) else { return nil } + return try JSONEncoder().encode(token) + } + ), + default: nil + ] + } +} diff --git a/Projects/Core/PickeStorage/Interface/Shared/PendingDeeplinkSharedKey.swift b/Projects/Core/PickeStorage/Interface/Shared/PendingDeeplinkSharedKey.swift new file mode 100644 index 00000000..83acb6de --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Shared/PendingDeeplinkSharedKey.swift @@ -0,0 +1,23 @@ +import Foundation + +import Dependencies +import Sharing + +public extension SharedReaderKey where Self == PersistentSharedKey.Default { + /// 메인 화면 진입 전 도착한 최신 딥링크. 소비한 nil도 저장해 이전 값을 다시 읽지 않는다. + static var pendingDeeplink: Self { + @Dependency(\.keyValueStorage) var storage + return Self[ + .persistent( + PushStorageKey.pendingDeeplink.rawValue, + encode: { try JSONEncoder().encode($0) }, + decode: { try JSONDecoder().decode(String?.self, from: $0) }, + legacyData: { + guard let value = storage.load(PushStorageKey.pendingDeeplink) else { return nil } + return try JSONEncoder().encode(value) + } + ), + default: nil + ] + } +} diff --git a/Projects/Core/PickeStorage/Interface/Shared/PersistentSharedKey.swift b/Projects/Core/PickeStorage/Interface/Shared/PersistentSharedKey.swift new file mode 100644 index 00000000..0111e12b --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Shared/PersistentSharedKey.swift @@ -0,0 +1,144 @@ +// +// PersistentSharedKey.swift +// PickeStorageInterface +// + +import Foundation + +import Dependencies +import Sharing + +public extension SharedReaderKey { + /// 라이브 앱에서는 조립된 영속 저장소를, 테스트·Preview 에서는 메모리 저장소를 쓴다. + static func persistent( + _ key: String, + encode: @escaping @Sendable (Value) throws -> Data, + decode: @escaping @Sendable (Data) throws -> Value, + legacyData: @escaping @Sendable () throws -> Data? = { nil } + ) -> Self where Self == PersistentSharedKey { + PersistentSharedKey( + key: key, + encode: encode, + decode: decode, + legacyData: legacyData + ) + } +} + +public struct PersistentSharedKey: SharedKey { + public struct ID: Hashable, Sendable { + fileprivate enum Location: Hashable, Sendable { + case persistent(SharedValueStorageIdentifier) + case inMemory(InMemoryStorage) + } + + fileprivate let key: String + fileprivate let location: Location + } + + private enum Storage: Sendable { + case persistent(any SharedValueStorage) + case inMemory(InMemoryKey, InMemoryStorage) + } + + private let key: String + private let storage: Storage + private let encode: @Sendable (Value) throws -> Data + private let decode: @Sendable (Data) throws -> Value + private let legacyData: @Sendable () throws -> Data? + + public var id: ID { + switch storage { + case let .persistent(storage): + ID(key: key, location: .persistent(storage.identifier)) + case let .inMemory(_, storage): + ID(key: key, location: .inMemory(storage)) + } + } + + public init( + key: String, + encode: @escaping @Sendable (Value) throws -> Data, + decode: @escaping @Sendable (Data) throws -> Value, + legacyData: @escaping @Sendable () throws -> Data? = { nil } + ) { + @Dependency(\.context) var context + + self.key = key + self.encode = encode + self.decode = decode + self.legacyData = legacyData + + switch context { + case .live: + @Dependency(\.sharedValueStorage) var storage + self.storage = .persistent(storage) + case .preview, .test: + @Dependency(\.defaultInMemoryStorage) var storage + let inMemoryKey: InMemoryKey = .inMemory(key) + self.storage = .inMemory(inMemoryKey, storage) + } + } + + public func load( + context: LoadContext, + continuation: LoadContinuation + ) { + switch storage { + case let .persistent(storage): + do { + if let data = try storage.load(forKey: key) { + continuation.resume(returning: try decode(data)) + } else if let data = try legacyData() { + // 기존 UserDefaults 값이 있으면 첫 조회 때 영속 저장소로 옮긴다. + try storage.save(data, forKey: key) + continuation.resume(returning: try decode(data)) + } else { + continuation.resumeReturningInitialValue() + } + } catch { + continuation.resume(throwing: error) + } + case let .inMemory(inMemoryKey, _): + inMemoryKey.load(context: context, continuation: continuation) + } + } + + public func subscribe( + context: LoadContext, + subscriber: SharedSubscriber + ) -> SharedSubscription { + switch storage { + case .persistent: + // 앱 안에서 같은 key 는 Sharing 의 persistent reference 를 공유한다. + // 이 테이블을 바꾸는 외부 writer 가 없어 별도 observation 이 필요 없다. + SharedSubscription {} + case let .inMemory(inMemoryKey, _): + inMemoryKey.subscribe(context: context, subscriber: subscriber) + } + } + + public func save( + _ value: Value, + context: SaveContext, + continuation: SaveContinuation + ) { + switch storage { + case let .persistent(storage): + do { + try storage.save(try encode(value), forKey: key) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + case let .inMemory(inMemoryKey, _): + inMemoryKey.save(value, context: context, continuation: continuation) + } + } +} + +extension PersistentSharedKey: CustomStringConvertible { + public var description: String { + ".persistent(\(String(reflecting: key)))" + } +} diff --git a/Projects/Core/PickeStorage/Interface/Shared/SharedValueStorage.swift b/Projects/Core/PickeStorage/Interface/Shared/SharedValueStorage.swift new file mode 100644 index 00000000..3c9d6d69 --- /dev/null +++ b/Projects/Core/PickeStorage/Interface/Shared/SharedValueStorage.swift @@ -0,0 +1,57 @@ +// +// SharedValueStorage.swift +// PickeStorageInterface +// + +import Foundation + +import Dependencies + +public struct SharedValueStorageIdentifier: Hashable, Sendable { + private let rawValue: UUID + + public init() { + rawValue = UUID() + } +} + +/// `@Shared` 값의 직렬화 결과를 보관하는 저장소 경계. +public protocol SharedValueStorage: Sendable { + var identifier: SharedValueStorageIdentifier { get } + + func load(forKey key: String) throws -> Data? + func save(_ data: Data, forKey key: String) throws + func remove(forKey key: String) throws +} + +public enum SharedValueStorageDependency: TestDependencyKey { + public static let testValue: any SharedValueStorage = UnimplementedSharedValueStorage() +} + +public extension DependencyValues { + var sharedValueStorage: any SharedValueStorage { + get { self[SharedValueStorageDependency.self] } + set { self[SharedValueStorageDependency.self] = newValue } + } +} + +/// 저장소를 갈아끼우지 않은 채 영속 경로를 타면 알려주는 기본값. +private struct UnimplementedSharedValueStorage: SharedValueStorage { + let identifier = SharedValueStorageIdentifier() + + func load(forKey key: String) throws -> Data? { + throw SharedValueStorageUnavailableError(key: key) + } + + func save(_: Data, forKey key: String) throws { + throw SharedValueStorageUnavailableError(key: key) + } + + func remove(forKey key: String) throws { + throw SharedValueStorageUnavailableError(key: key) + } +} + +private struct SharedValueStorageUnavailableError: Error { + let key: String +} diff --git a/Projects/Core/PickeStorage/Project.swift b/Projects/Core/PickeStorage/Project.swift new file mode 100644 index 00000000..fef2666d --- /dev/null +++ b/Projects/Core/PickeStorage/Project.swift @@ -0,0 +1,28 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeStorage", + bundleId: .appBundleID(name: ".PickeStorage"), + product: .framework, + settings: .settings(), + dependencies: [ + .core(.logger), + // Sources 가 ComposableArchitecture(DependencyKey)를 직접 import 한다. + .SPM.composableArchitecture, + .SPM.sqliteData, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + .SPM.sharing, + .SPM.sqliteData, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Core/PickeStorage/Sources/DependencyValues+Storage.swift b/Projects/Core/PickeStorage/Sources/DependencyValues+Storage.swift new file mode 100644 index 00000000..3f3ed138 --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/DependencyValues+Storage.swift @@ -0,0 +1,27 @@ +// +// DependencyValues+Storage.swift +// PickeStorage +// + +import PickeStorageInterface + +import Dependencies +import SQLiteData + +extension AppDatabaseDependency: DependencyKey { + public static var liveValue: (any DatabaseWriter)? { + StorageFactory.databaseWriter + } +} + +extension SharedValueStorageDependency: DependencyKey { + public static var liveValue: any SharedValueStorage { + StorageFactory.sharedValueStorage + } +} + +extension KeyValueStorageDependency: DependencyKey { + public static var liveValue: any KeyValueStorage { + StorageFactory.keyValueStorage + } +} diff --git a/Projects/Core/PickeStorage/Sources/Factory/StorageFactory.swift b/Projects/Core/PickeStorage/Sources/Factory/StorageFactory.swift new file mode 100644 index 00000000..2a59424c --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/Factory/StorageFactory.swift @@ -0,0 +1,66 @@ +// +// StorageFactory.swift +// PickeStorage +// + +import PickeCoreLogger + +import PickeStorageInterface + +import Dependencies +import SQLiteData + +public enum StorageFactory { + private static let database = prepareDatabase() + + static func prepareDatabase( + primary: () throws -> any DatabaseWriter = { try SQLiteData.defaultDatabase() }, + fallback: () throws -> any DatabaseWriter = { try DatabaseQueue() }, + migrate: (any DatabaseWriter) throws -> Void = AppDatabaseMigrator.migrate + ) -> (any DatabaseWriter)? { + do { + let database = try primary() + try migrate(database) + return database + } catch { + PickeLogger.error("앱 데이터베이스 준비 실패: \(String(describing: error))", category: .storage) + } + + do { + let database = try fallback() + try migrate(database) + return database + } catch { + PickeLogger.error("메모리 데이터베이스 준비 실패: \(String(describing: error))", category: .storage) + return nil + } + } + + public static var secureStorage: any SecureStorage { + KeychainStorage() + } + + public static var sharedValueStorage: any SharedValueStorage { + guard let database else { + return VolatileSharedValueStorage() + } + return SQLiteSharedValueStorage(database: database) + } + + public static var keyValueStorage: any KeyValueStorage { + UserDefaultStore() + } + + public static var databaseWriter: (any DatabaseWriter)? { + database + } + + public static func register(into values: inout DependencyValues) { + if let database { + values.defaultDatabase = database + values.appDatabase = database + } + values.sharedValueStorage = sharedValueStorage + values.keyValueStorage = keyValueStorage + } +} diff --git a/Projects/Core/PickeStorage/Sources/KeyValue/UserDefaultStore.swift b/Projects/Core/PickeStorage/Sources/KeyValue/UserDefaultStore.swift new file mode 100644 index 00000000..3023d88c --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/KeyValue/UserDefaultStore.swift @@ -0,0 +1,34 @@ +// +// UserDefaultStore.swift +// PickeStorage +// + +import Foundation + +import PickeStorageInterface + +public struct UserDefaultStore: KeyValueStorage { + private let suiteName: String? + + private var userDefaults: UserDefaults? { + guard let suiteName else { return .standard } + return UserDefaults(suiteName: suiteName) + } + + public init(suiteName: String? = nil) { + self.suiteName = suiteName + } + + public func load(_ key: KeyValueStorageKey) -> Value? { + userDefaults?.object(forKey: key.rawValue) as? Value + } + + public func save(_ value: Value?, for key: KeyValueStorageKey) { + guard let userDefaults else { return } + guard let value else { + userDefaults.removeObject(forKey: key.rawValue) + return + } + userDefaults.set(value, forKey: key.rawValue) + } +} diff --git a/Projects/Core/PickeStorage/Sources/Secure/KeychainClient.swift b/Projects/Core/PickeStorage/Sources/Secure/KeychainClient.swift new file mode 100644 index 00000000..bea3f342 --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/Secure/KeychainClient.swift @@ -0,0 +1,59 @@ +// +// KeychainClient.swift +// PickeStorage +// + +import Foundation +import Security + +/// KeychainStorage 가 운영체제 Security API 와 주고받는 내부 경계. +/// 테스트에서는 entitlement 가 필요 없는 메모리 구현으로 갈아끼운다. +protocol KeychainClient: Sendable { + func update(_ data: Data, service: String, account: String) -> OSStatus + func add(_ data: Data, service: String, account: String) -> OSStatus + func load(service: String, account: String) -> KeychainLoadResult + func delete(service: String, account: String) -> OSStatus +} + +struct KeychainLoadResult: Sendable { + let status: OSStatus + let data: Data? +} + +struct SystemKeychainClient: KeychainClient { + func update(_ data: Data, service: String, account: String) -> OSStatus { + SecItemUpdate( + baseQuery(service: service, account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + } + + func add(_ data: Data, service: String, account: String) -> OSStatus { + var query = baseQuery(service: service, account: account) + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + return SecItemAdd(query as CFDictionary, nil) + } + + func load(service: String, account: String) -> KeychainLoadResult { + var query = baseQuery(service: service, account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &item) + return KeychainLoadResult(status: status, data: item as? Data) + } + + func delete(service: String, account: String) -> OSStatus { + SecItemDelete(baseQuery(service: service, account: account) as CFDictionary) + } + + private func baseQuery(service: String, account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} diff --git a/Projects/Core/PickeStorage/Sources/Secure/KeychainStorage.swift b/Projects/Core/PickeStorage/Sources/Secure/KeychainStorage.swift new file mode 100644 index 00000000..853afd95 --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/Secure/KeychainStorage.swift @@ -0,0 +1,72 @@ +// +// KeychainStorage.swift +// PickeStorage +// + +import Foundation +import Security + +import PickeStorageInterface + +struct KeychainStorage: SecureStorage { + // 이전 버전이 저장한 Keychain 항목을 업데이트 후에도 조회할 수 있어야 한다. + static let defaultService = "io.Picke.co" + + private let service: String + private let client: any KeychainClient + + init( + service: String = Self.defaultService, + client: any KeychainClient = SystemKeychainClient() + ) { + self.service = service + self.client = client + } + + func save(_ value: String, for key: SecureStorageKey) throws(SecureStorageError) { + let data = Data(value.utf8) + let status = client.update(data, service: service, account: key.rawValue) + + switch status { + case errSecSuccess: + return + case errSecItemNotFound: + let addStatus = client.add(data, service: service, account: key.rawValue) + guard addStatus == errSecSuccess else { + throw SecureStorageError.unexpectedStatus(addStatus) + } + default: + throw SecureStorageError.unexpectedStatus(status) + } + } + + func load(_ key: SecureStorageKey) throws(SecureStorageError) -> String? { + let result = client.load(service: service, account: key.rawValue) + + switch result.status { + case errSecSuccess: + guard let data = result.data else { return nil } + guard let value = String(data: data, encoding: .utf8) else { + throw SecureStorageError.invalidData + } + return value + case errSecItemNotFound: + return nil + default: + throw SecureStorageError.unexpectedStatus(result.status) + } + } + + func remove(_ key: SecureStorageKey) throws(SecureStorageError) { + let status = client.delete(service: service, account: key.rawValue) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw SecureStorageError.unexpectedStatus(status) + } + } + + func removeAll() throws(SecureStorageError) { + for key in SecureStorageKey.all { + try remove(key) + } + } +} diff --git a/Projects/Core/PickeStorage/Sources/Shared/AppDatabaseMigrator.swift b/Projects/Core/PickeStorage/Sources/Shared/AppDatabaseMigrator.swift new file mode 100644 index 00000000..5495ec75 --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/Shared/AppDatabaseMigrator.swift @@ -0,0 +1,28 @@ +// +// AppDatabaseMigrator.swift +// PickeStorage +// + +import Foundation + +import SQLiteData + +enum AppDatabaseMigrator { + static func migrate(_ database: any DatabaseWriter) throws { + var migrator = DatabaseMigrator() + + migrator.registerMigration("2026-09-07-create-shared-values") { db in + try #sql( + """ + CREATE TABLE IF NOT EXISTS sharedValues ( + key TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ) + """ + ) + .execute(db) + } + + try migrator.migrate(database) + } +} diff --git a/Projects/Core/PickeStorage/Sources/Shared/SQLiteSharedValueStorage.swift b/Projects/Core/PickeStorage/Sources/Shared/SQLiteSharedValueStorage.swift new file mode 100644 index 00000000..cedabe6e --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/Shared/SQLiteSharedValueStorage.swift @@ -0,0 +1,46 @@ +// +// SQLiteSharedValueStorage.swift +// PickeStorage +// + +import Foundation + +import PickeStorageInterface +import SQLiteData + +@Table("sharedValues") +private struct SharedValueRecord: Sendable { + @Column(primaryKey: true) + let key: String + let value: Data +} + +struct SQLiteSharedValueStorage: SharedValueStorage { + let identifier = SharedValueStorageIdentifier() + + private let database: any DatabaseWriter + + init(database: any DatabaseWriter) { + self.database = database + } + + func load(forKey key: String) throws -> Data? { + try database.read { db in + try SharedValueRecord.find(key).fetchOne(db)?.value + } + } + + func save(_ data: Data, forKey key: String) throws { + try database.write { db in + try SharedValueRecord + .upsert { SharedValueRecord(key: key, value: data) } + .execute(db) + } + } + + func remove(forKey key: String) throws { + try database.write { db in + try SharedValueRecord.find(key).delete().execute(db) + } + } +} diff --git a/Projects/Core/PickeStorage/Sources/Shared/VolatileSharedValueStorage.swift b/Projects/Core/PickeStorage/Sources/Shared/VolatileSharedValueStorage.swift new file mode 100644 index 00000000..8594e1d1 --- /dev/null +++ b/Projects/Core/PickeStorage/Sources/Shared/VolatileSharedValueStorage.swift @@ -0,0 +1,28 @@ +// +// VolatileSharedValueStorage.swift +// PickeStorage +// + +import Foundation + +import PickeStorageInterface + +/// SQLite 준비에 실패한 실행에서도 앱이 돌아가게 하는 비영속 fallback. +final class VolatileSharedValueStorage: SharedValueStorage, @unchecked Sendable { + let identifier = SharedValueStorageIdentifier() + + private let lock = NSLock() + private var values: [String: Data] = [:] + + func load(forKey key: String) throws -> Data? { + lock.withLock { values[key] } + } + + func save(_ data: Data, forKey key: String) throws { + lock.withLock { values[key] = data } + } + + func remove(forKey key: String) throws { + lock.withLock { values[key] = nil } + } +} diff --git a/Projects/Core/PickeStorage/Tests/Sources/KeychainStorageTests.swift b/Projects/Core/PickeStorage/Tests/Sources/KeychainStorageTests.swift new file mode 100644 index 00000000..78c94977 --- /dev/null +++ b/Projects/Core/PickeStorage/Tests/Sources/KeychainStorageTests.swift @@ -0,0 +1,139 @@ +// +// KeychainStorageTests.swift +// PickeStorageTests +// + +import Foundation +import Security +import Testing + +@testable import PickeStorage + +@Suite("KeychainStorage", .serialized) +struct KeychainStorageTests { + @Test + func 기존_앱의_Keychain_service_식별자를_유지한다() { + #expect(KeychainStorage.defaultService == "io.Picke.co") + } + + @Test + func 저장한_값을_같은_키로_조회한다() throws { + let storage = makeStorage() + defer { try? storage.removeAll() } + + try storage.save("access-token", for: .accessToken) + + #expect(try storage.load(.accessToken) == "access-token") + } + + @Test + func 같은_키에_다시_저장하면_기존_값을_덮어쓴다() throws { + let storage = makeStorage() + defer { try? storage.removeAll() } + + try storage.save("old-token", for: .accessToken) + try storage.save("new-token", for: .accessToken) + + #expect(try storage.load(.accessToken) == "new-token") + } + + @Test + func 서로_다른_키의_값을_독립적으로_보관한다() throws { + let storage = makeStorage() + defer { try? storage.removeAll() } + + try storage.save("access-token", for: .accessToken) + try storage.save("refresh-token", for: .refreshToken) + + #expect(try storage.load(.accessToken) == "access-token") + #expect(try storage.load(.refreshToken) == "refresh-token") + } + + @Test + func 빈_문자열도_손실없이_저장한다() throws { + let storage = makeStorage() + defer { try? storage.removeAll() } + + try storage.save("", for: .accessToken) + + #expect(try storage.load(.accessToken) == "") + } + + @Test + func 단건_삭제는_다른_키에_영향을_주지_않는다() throws { + let storage = makeStorage() + defer { try? storage.removeAll() } + + try storage.save("access-token", for: .accessToken) + try storage.save("refresh-token", for: .refreshToken) + try storage.remove(.accessToken) + + #expect(try storage.load(.accessToken) == nil) + #expect(try storage.load(.refreshToken) == "refresh-token") + } + + @Test + func 존재하지_않는_키를_삭제해도_성공한다() throws { + let storage = makeStorage() + + try storage.remove(.accessToken) + + #expect(try storage.load(.accessToken) == nil) + } + + @Test + func 전체_삭제는_등록된_모든_토큰을_제거한다() throws { + let storage = makeStorage() + + try storage.save("access-token", for: .accessToken) + try storage.save("refresh-token", for: .refreshToken) + try storage.removeAll() + + #expect(try storage.load(.accessToken) == nil) + #expect(try storage.load(.refreshToken) == nil) + } +} + +private extension KeychainStorageTests { + /// entitlement 없이 도는 메모리 클라이언트로 매 테스트마다 격리된 저장소를 만든다. + func makeStorage() -> KeychainStorage { + KeychainStorage( + service: "io.Picke.co.tests.\(UUID().uuidString)", + client: InMemoryKeychainClient() + ) + } +} + +private final class InMemoryKeychainClient: KeychainClient, @unchecked Sendable { + private var values: [String: Data] = [:] + + func update(_ data: Data, service: String, account: String) -> OSStatus { + let key = storageKey(service: service, account: account) + guard values[key] != nil else { return errSecItemNotFound } + values[key] = data + return errSecSuccess + } + + func add(_ data: Data, service: String, account: String) -> OSStatus { + let key = storageKey(service: service, account: account) + guard values[key] == nil else { return errSecDuplicateItem } + values[key] = data + return errSecSuccess + } + + func load(service: String, account: String) -> KeychainLoadResult { + guard let data = values[storageKey(service: service, account: account)] else { + return KeychainLoadResult(status: errSecItemNotFound, data: nil) + } + return KeychainLoadResult(status: errSecSuccess, data: data) + } + + func delete(service: String, account: String) -> OSStatus { + let removed = values.removeValue(forKey: storageKey(service: service, account: account)) + return removed == nil ? errSecItemNotFound : errSecSuccess + } + + private func storageKey(service: String, account: String) -> String { + "\(service):\(account)" + } +} diff --git a/Projects/Core/PickeStorage/Tests/Sources/SQLiteSharedValueStorageTests.swift b/Projects/Core/PickeStorage/Tests/Sources/SQLiteSharedValueStorageTests.swift new file mode 100644 index 00000000..4dc6448c --- /dev/null +++ b/Projects/Core/PickeStorage/Tests/Sources/SQLiteSharedValueStorageTests.swift @@ -0,0 +1,42 @@ +// +// SQLiteSharedValueStorageTests.swift +// PickeStorageTests +// + +import Foundation +import Testing + +@testable import PickeStorage + +import SQLiteData + +@Suite("SQLiteSharedValueStorage") +struct SQLiteSharedValueStorageTests { + @Test + func 저장_조회_덮어쓰기_삭제를_지원한다() throws { + let database = try DatabaseQueue() + try AppDatabaseMigrator.migrate(database) + let storage = SQLiteSharedValueStorage(database: database) + + try storage.save(Data("first".utf8), forKey: "session") + #expect(try storage.load(forKey: "session") == Data("first".utf8)) + + try storage.save(Data("second".utf8), forKey: "session") + #expect(try storage.load(forKey: "session") == Data("second".utf8)) + + try storage.remove(forKey: "session") + #expect(try storage.load(forKey: "session") == nil) + } + + @Test + func 마이그레이션은_여러번_실행해도_안전하다() throws { + let database = try DatabaseQueue() + + try AppDatabaseMigrator.migrate(database) + try AppDatabaseMigrator.migrate(database) + + let storage = SQLiteSharedValueStorage(database: database) + try storage.save(Data("value".utf8), forKey: "key") + #expect(try storage.load(forKey: "key") == Data("value".utf8)) + } +} diff --git a/Projects/Core/PickeStorage/Tests/Sources/StorageFactoryTests.swift b/Projects/Core/PickeStorage/Tests/Sources/StorageFactoryTests.swift new file mode 100644 index 00000000..0234d6df --- /dev/null +++ b/Projects/Core/PickeStorage/Tests/Sources/StorageFactoryTests.swift @@ -0,0 +1,130 @@ +// +// StorageFactoryTests.swift +// PickeStorageTests +// + +import Foundation +import Testing + +@testable import PickeStorage +import PickeStorageInterface + +import ComposableArchitecture +import SQLiteData + +@Suite("StorageFactory") +struct StorageFactoryTests { + private enum PreparationError: Error { + case failed + } + + @Test + func secureStorage_를_생성한다() { + let storage: any SecureStorage = StorageFactory.secureStorage + + #expect(String(describing: type(of: storage)) == "KeychainStorage") + } + + @Test("기본 데이터베이스 생성 실패 시 마이그레이션을 마친 메모리 데이터베이스를 사용한다") + func databasePreparationFallsBackToMigratedMemoryDatabase() throws { + var fallbackCount = 0 + var migrationCount = 0 + + let database = StorageFactory.prepareDatabase( + primary: { throw PreparationError.failed }, + fallback: { + fallbackCount += 1 + return try DatabaseQueue() + }, + migrate: { _ in migrationCount += 1 } + ) + + #expect(database != nil) + #expect(fallbackCount == 1) + #expect(migrationCount == 1) + } + + @Test("기본 데이터베이스 마이그레이션 실패 시 해당 데이터베이스를 노출하지 않는다") + func databasePreparationDoesNotExposeUnmigratedPrimaryDatabase() throws { + var migrationCount = 0 + let primary = try DatabaseQueue() + let fallback = try DatabaseQueue() + + let database = StorageFactory.prepareDatabase( + primary: { primary }, + fallback: { fallback }, + migrate: { database in + migrationCount += 1 + if migrationCount == 1 { + throw PreparationError.failed + } + try AppDatabaseMigrator.migrate(database) + } + ) + + #expect(database != nil) + #expect(migrationCount == 2) + } + + @Test("기본 저장소와 메모리 저장소가 모두 실패해도 강제 종료하지 않는다") + func databasePreparationReturnsNilWhenEveryAttemptFails() { + let database = StorageFactory.prepareDatabase( + primary: { throw PreparationError.failed }, + fallback: { throw PreparationError.failed }, + migrate: { _ in } + ) + + #expect(database == nil) + } +} + +@Suite("SecureStorageKey") +struct SecureStorageKeyTests { + @Test + func 기존_토큰_키_문자열을_유지한다() { + #expect(SecureStorageKey.accessToken.rawValue == "ACCESS_TOKEN") + #expect(SecureStorageKey.refreshToken.rawValue == "REFRESH_TOKEN") + } + + @Test + func 전체_삭제_대상에는_모든_토큰_키가_한번씩_포함된다() { + #expect(Set(SecureStorageKey.all) == [.accessToken, .refreshToken]) + #expect(SecureStorageKey.all.count == 2) + } +} + +@Suite("DeviceTokenStorage", .serialized) +struct DeviceTokenStorageTests { + @Test + func 기존_UserDefaults_키_문자열을_유지하고_PickeStorage로_이관한다() throws { + let userDefaultStore = UserDefaultStore(suiteName: "DeviceTokenStorageTests.\(UUID().uuidString)") + let database = try DatabaseQueue() + try AppDatabaseMigrator.migrate(database) + let sharedValueStorage = SQLiteSharedValueStorage(database: database) + + userDefaultStore.save("legacy-token", for: PushStorageKey.deviceToken) + defer { userDefaultStore.save(nil, for: PushStorageKey.deviceToken) } + + withDependencies { + $0.context = .live + $0.sharedValueStorage = sharedValueStorage + $0.keyValueStorage = userDefaultStore + } operation: { + #expect(DeviceTokenStorage.token == "legacy-token") + #expect((try? sharedValueStorage.load(forKey: PushStorageKey.deviceToken.rawValue)) != nil) + } + } + + @Test + func nil_저장시_토큰을_비운다() { + withDependencies { + $0.context = .test + } operation: { + DeviceTokenStorage.token = "token" + + DeviceTokenStorage.token = nil + + #expect(DeviceTokenStorage.token == nil) + } + } +} diff --git a/Projects/Core/PickeStorage/Tests/Sources/UserDefaultStoreTests.swift b/Projects/Core/PickeStorage/Tests/Sources/UserDefaultStoreTests.swift new file mode 100644 index 00000000..80d35843 --- /dev/null +++ b/Projects/Core/PickeStorage/Tests/Sources/UserDefaultStoreTests.swift @@ -0,0 +1,34 @@ +import Foundation +import PickeStorageInterface +import Testing + +@testable import PickeStorage + +struct UserDefaultStoreTests { + @Test + func 푸시_키는_이전_문자열을_유지한다() { + #expect(PushStorageKey.deviceToken.rawValue == "PickeDeviceToken") + #expect(PushStorageKey.pendingDeeplink.rawValue == "PickePendingDeeplink") + } + + @Test + func 타입_키로_저장하고_nil로_제거한다() { + let store = UserDefaultStore(suiteName: "PickeStorageTests.\(UUID().uuidString)") + defer { store.save(nil, for: PushStorageKey.deviceToken) } + #expect(store.load(PushStorageKey.deviceToken) == nil) + store.save("token", for: PushStorageKey.deviceToken) + #expect(store.load(PushStorageKey.deviceToken) == "token") + store.save(nil, for: PushStorageKey.deviceToken) + #expect(store.load(PushStorageKey.deviceToken) == nil) + } + + @Test + func 서로_다른_suite의_값을_공유하지_않는다() { + let first = UserDefaultStore(suiteName: "PickeStorageTests.\(UUID().uuidString)") + let second = UserDefaultStore(suiteName: "PickeStorageTests.\(UUID().uuidString)") + defer { first.save(nil, for: PushStorageKey.pendingDeeplink) } + first.save("quick-battle", for: PushStorageKey.pendingDeeplink) + #expect(first.load(PushStorageKey.pendingDeeplink) == "quick-battle") + #expect(second.load(PushStorageKey.pendingDeeplink) == nil) + } +} diff --git a/Projects/Shared/ThirdParty/Project.swift b/Projects/Core/PickeThirdParty/Project.swift similarity index 52% rename from Projects/Shared/ThirdParty/Project.swift rename to Projects/Core/PickeThirdParty/Project.swift index e6c5bf54..61c18305 100644 --- a/Projects/Shared/ThirdParty/Project.swift +++ b/Projects/Core/PickeThirdParty/Project.swift @@ -1,19 +1,21 @@ import Foundation -import ProjectDescription + +import DependencyPackagePlugin import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin -let project = Project.configure( - moduleType: .module(name: "ThirdParty"), - bundleId: .appBundleID(name: ".ThirdParty"), - product: .staticFramework, +import ProjectDescription + +let project = Project.makeModule( + name: "PickeThirdParty", + bundleId: .appBundleID(name: ".PickeThirdParty"), + product: .framework, settings: .settings(), dependencies: [ + .core(.coreUtility), .SPM.composableArchitecture, .SPM.tcaFlow, - .SPM.sdwebImage - + .SPM.sdwebImage, ], - sources: ["Sources/**"] -) + hasTests: false +) \ No newline at end of file diff --git a/Projects/Data/Model/Sources/Base.swift b/Projects/Core/PickeThirdParty/Sources/Base.swift similarity index 69% rename from Projects/Data/Model/Sources/Base.swift rename to Projects/Core/PickeThirdParty/Sources/Base.swift index 6297cc4c..a07b61d2 100644 --- a/Projects/Data/Model/Sources/Base.swift +++ b/Projects/Core/PickeThirdParty/Sources/Base.swift @@ -1,9 +1,6 @@ // -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd., All rights reserved. +// Base.swift +// PickeThirdParty // import SwiftUI diff --git a/Projects/Data/API/APITests/Sources/Test.swift b/Projects/Data/API/APITests/Sources/Test.swift deleted file mode 100644 index 88831a39..00000000 --- a/Projects/Data/API/APITests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Data/Attendance/Project.swift b/Projects/Data/Attendance/Project.swift deleted file mode 100644 index 3cb585bd..00000000 --- a/Projects/Data/Attendance/Project.swift +++ /dev/null @@ -1,23 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "AttendanceData"), - bundleId: .appBundleID(name: ".AttendanceData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Attendance, .interface), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Attendance/Sources/Repository/AttendanceRepositoryImpl.swift b/Projects/Data/Attendance/Sources/Repository/AttendanceRepositoryImpl.swift deleted file mode 100644 index ea284148..00000000 --- a/Projects/Data/Attendance/Sources/Repository/AttendanceRepositoryImpl.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// AttendanceRepositoryImpl.swift -// Repository -// - -import Foundation - -import AttendanceDomainInterface -import Model -import Repository - -import LogMacro - -public final class AttendanceRepositoryImpl: AttendanceInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func checkAttendance() async throws -> AttendanceCheckResult { - let dto: AttendanceCheckResponseDTO = try await provider.request(.check) - - guard let data = dto.data else { - let message = dto.error?.message ?? "출석 체크 응답이 비어 있습니다" - Log.error("[AttendanceRepositoryImpl] empty check payload: \(message)") - throw AttendanceError.backendError(message) - } - - return data.toDomain() - } - - public func fetchWeeklyAttendance() async throws -> WeeklyAttendance { - let dto: WeeklyAttendanceResponseDTO = try await provider.request(.weekly) - - guard let data = dto.data else { - let message = dto.error?.message ?? "주간 출석 응답이 비어 있습니다" - Log.error("[AttendanceRepositoryImpl] empty weekly payload: \(message)") - throw AttendanceError.backendError(message) - } - - return data.toDomain() - } - - public func fetchAttendanceSummary() async throws -> AttendanceSummary { - let dto: AttendanceSummaryResponseDTO = try await provider.request(.summary) - - guard let data = dto.data else { - let message = dto.error?.message ?? "출석 통계 응답이 비어 있습니다" - Log.error("[AttendanceRepositoryImpl] empty summary payload: \(message)") - throw AttendanceError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Attendance/Tests/TestSupport.swift b/Projects/Data/Attendance/Tests/TestSupport.swift deleted file mode 100644 index cf2dfba4..00000000 --- a/Projects/Data/Attendance/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// AttendanceDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Auth/Project.swift b/Projects/Data/Auth/Project.swift deleted file mode 100644 index 3db8d880..00000000 --- a/Projects/Data/Auth/Project.swift +++ /dev/null @@ -1,27 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "AuthData"), - bundleId: .appBundleID(name: ".AuthData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Auth, .interface), - .Domain(implements: .Entity), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.weaveDI, - .SPM.logMarco, - .SPM.composableArchitecture, - .SPM.googleSignIn, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Auth/Tests/TestSupport.swift b/Projects/Data/Auth/Tests/TestSupport.swift deleted file mode 100644 index b255f045..00000000 --- a/Projects/Data/Auth/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// AuthDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Battle/Project.swift b/Projects/Data/Battle/Project.swift deleted file mode 100644 index f445a277..00000000 --- a/Projects/Data/Battle/Project.swift +++ /dev/null @@ -1,26 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "BattleData"), - bundleId: .appBundleID(name: ".BattleData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Battle, .interface), - .Domain(.Common, .interface), - .Domain(implements: .Entity), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.weaveDI, - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Battle/Sources/Repository/BattleRepositoryImpl.swift b/Projects/Data/Battle/Sources/Repository/BattleRepositoryImpl.swift deleted file mode 100644 index bbca1acc..00000000 --- a/Projects/Data/Battle/Sources/Repository/BattleRepositoryImpl.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// BattleRepositoryImpl.swift -// Repository -// - -import Foundation - -import BattleDomainInterface -import CommonDomainInterface -import DomainInterface -import Entity -import HomeDomainInterface -import Model -import Repository -import Service - -import LogMacro - -public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func fetchTodayBattles() async throws -> TodayBattlePage { - let dto: TodayBattlePageResponseDTO = try await provider.request(.today) - - guard let data = dto.data else { - let message = dto.error?.message ?? "오늘의 배틀 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty todayBattles payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func fetchBattle(battleId: Int) async throws -> BattleDetail { - let dto: BattleDetailResponseDTO = try await provider.request( - .detail(battleId: battleId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "배틀 상세 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty battleDetail payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func submitPreVote( - battleId: Int, - optionId: Int - ) async throws -> PreVoteResult { - let dto: PreVoteResponseDTO = try await provider.request( - .preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "사전 투표 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty preVote payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats { - let dto: BattleVoteStatsResponseDTO = try await provider.request( - .voteStats(battleId: battleId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "투표 통계 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty voteStats payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func submitPostVote( - battleId: Int, - optionId: Int - ) async throws -> PreVoteResult { - let dto: PreVoteResponseDTO = try await provider.request( - .postVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "최종 투표 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty postVote payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func fetchPerspectives( - battleId: Int, - cursor: String?, - size: Int?, - optionId: Int?, - sort: BattlePerspectiveSort? - ) async throws -> BattlePerspectivePage { - let dto: BattlePerspectivePageResponseDTO = try await provider.request( - .perspectives( - battleId: battleId, - query: PerspectivesQueryRequest( - cursor: cursor, - size: size, - optionId: optionId, - sort: sort?.queryValue - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "댓글 목록 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty perspectives payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func createPerspective( - battleId: Int, - content: String, - optionId: Int? - ) async throws -> BattlePerspective? { - let dto: CreatePerspectiveResponseDTO = try await provider.request( - .createPerspective( - battleId: battleId, - body: CreatePerspectiveRequest(content: content, optionId: optionId) - ) - ) - - guard dto.data != nil else { - let message = dto.error?.message ?? "댓글 작성 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty createPerspective payload: \(message)") - throw BattleError.backendError(message) - } - - // 등록 응답에는 진영(option)이 없어 재조회로 채운다. 재조회가 실패해도 등록은 이미 성공했으므로 - // 실패로 뒤집지 않고 nil 을 돌려준다 — 호출부는 목록만 갱신하고 진영 전환을 건너뛴다. - let perspective = try? await fetchMyPerspective(battleId: battleId) - if perspective == nil { - Log.error("[BattleRepositoryImpl] createPerspective 재조회 실패 — 등록은 성공") - } - return perspective - } - - public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { - let dto: BaseResponseDTO - do { - dto = try await provider.request(.myPerspective(battleId: battleId)) - } catch { - Log.debug("[BattleRepositoryImpl] fetchMyPerspective failed (no participation): \(error.localizedDescription)") - return nil - } - - if dto.statusCode >= 400 { - return nil - } - return dto.data?.toDomain() - } - - public func fetchScenario(battleId: Int) async throws -> BattleScenario { - let dto: BattleScenarioResponseDTO = try await provider.request( - .scenario(battleId: battleId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "시나리오 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty scenario payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { - let dto: RecommendedBattlePageResponseDTO = try await provider.request( - .recommendations(battleId: battleId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "추천 배틀 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty recommendations payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } - - public func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal { - let dto: BattleProposalResponseDTO = try await provider.request( - .createProposal( - body: BattleProposalRequest( - category: draft.category, - topic: draft.topic, - positionA: draft.positionA, - positionB: draft.positionB, - description: draft.description - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "배틀 제안 응답이 비어 있습니다" - Log.error("[BattleRepositoryImpl] empty proposeBattle payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Battle/Tests/TestSupport.swift b/Projects/Data/Battle/Tests/TestSupport.swift deleted file mode 100644 index 204c6d44..00000000 --- a/Projects/Data/Battle/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// BattleDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Comment/Project.swift b/Projects/Data/Comment/Project.swift deleted file mode 100644 index 6821fdf0..00000000 --- a/Projects/Data/Comment/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "CommentData"), - bundleId: .appBundleID(name: ".CommentData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Comment, .interface), - .Domain(.Common, .interface), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Comment/Sources/Repository/CommentRepositoryImpl.swift b/Projects/Data/Comment/Sources/Repository/CommentRepositoryImpl.swift deleted file mode 100644 index ee2e93ca..00000000 --- a/Projects/Data/Comment/Sources/Repository/CommentRepositoryImpl.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// CommentRepositoryImpl.swift -// Repository -// - -import Foundation - -import CommentDomainInterface -import CommonDomainInterface -import Model -import Repository - -import LogMacro - - -public final class CommentRepositoryImpl: CommentInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func likeComment(commentId: Int) async throws -> CommentLikeResult { - let dto: CommentLikeResponseDTO = try await provider.request(.like(commentId: commentId)) - - guard let data = dto.data else { - let message = dto.error?.message ?? "댓글 좋아요 응답이 비어 있습니다" - Log.error("[CommentRepositoryImpl] empty like payload: \(message)") - throw CommentError.backendError(message) - } - - return data.toDomain() - } - - public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { - let dto: CommentLikeResponseDTO = try await provider.request(.unlike(commentId: commentId)) - - guard let data = dto.data else { - let message = dto.error?.message ?? "댓글 좋아요 취소 응답이 비어 있습니다" - Log.error("[CommentRepositoryImpl] empty unlike payload: \(message)") - throw CommentError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Comment/Tests/TestSupport.swift b/Projects/Data/Comment/Tests/TestSupport.swift deleted file mode 100644 index a2eada1f..00000000 --- a/Projects/Data/Comment/Tests/TestSupport.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// TestSupport.swift -// CommentDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Data/Project.swift b/Projects/Data/Data/Project.swift deleted file mode 100644 index 1a1b3965..00000000 --- a/Projects/Data/Data/Project.swift +++ /dev/null @@ -1,28 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Data"), - bundleId: .appBundleID(name: ".Data"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Data(.Attendance), - .Data(.Auth), - .Data(.Battle), - .Data(.Search), - .Data(.Comment), - .Data(.Home), - .Data(.Notification), - .Data(.Perspective), - .Data(.Profile), - ], - sources: ["Sources/**"] -) diff --git a/Projects/Data/Data/Sources/Exported/DataExported.swift b/Projects/Data/Data/Sources/Exported/DataExported.swift deleted file mode 100644 index 1b1719b8..00000000 --- a/Projects/Data/Data/Sources/Exported/DataExported.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// DataExported.swift -// Data -// - -// MARK: - Data 레이어 한번에 노출 - -@_exported import API -@_exported import AuthData -@_exported import BattleData -@_exported import CommentData -@_exported import CommentData -@_exported import HomeData -@_exported import Model -@_exported import NotificationData -@_exported import PerspectiveData -@_exported import ProfileData -@_exported import Repository -@_exported import SearchData -@_exported import Service diff --git a/Projects/Data/DataTesting/Project.swift b/Projects/Data/DataTesting/Project.swift deleted file mode 100644 index 0b06360b..00000000 --- a/Projects/Data/DataTesting/Project.swift +++ /dev/null @@ -1,17 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "DataTesting"), - bundleId: .appBundleID(name: ".DataTesting"), - product: .framework, - settings: .settings(), - dependencies: [ - .Domain(implements: .Entity), - .Domain(implements: .DomainInterface), - ], - sources: ["Sources/**"] -) diff --git a/Projects/Data/DataTesting/Sources/AppUpdate/FakeAppUpdateRepository.swift b/Projects/Data/DataTesting/Sources/AppUpdate/FakeAppUpdateRepository.swift deleted file mode 100644 index 4189acd7..00000000 --- a/Projects/Data/DataTesting/Sources/AppUpdate/FakeAppUpdateRepository.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// FakeAppUpdateRepository.swift -// DataTesting -// - -import DomainInterface -import Entity - -public struct FakeAppUpdateRepository: AppUpdateInterface { - public var info: AppUpdateInfo - - public init(info: AppUpdateInfo) { - self.info = info - } - - public func checkForUpdate() async throws -> AppUpdateInfo { - info - } -} diff --git a/Projects/Data/Home/Project.swift b/Projects/Data/Home/Project.swift deleted file mode 100644 index 2fd4624b..00000000 --- a/Projects/Data/Home/Project.swift +++ /dev/null @@ -1,25 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "HomeData"), - bundleId: .appBundleID(name: ".HomeData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Home, .interface), - .Domain(.Common, .interface), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.weaveDI, - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Home/Sources/Repository/HomeRepositoryImpl.swift b/Projects/Data/Home/Sources/Repository/HomeRepositoryImpl.swift deleted file mode 100644 index 03f96a90..00000000 --- a/Projects/Data/Home/Sources/Repository/HomeRepositoryImpl.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// HomeRepositoryImpl.swift -// Repository -// -// Created by Wonji Suh on 5/16/26. -// - -import Foundation - -import Entity -import HomeDomainInterface -import Model -import Repository - -import LogMacro - - -public final class HomeRepositoryImpl: HomeInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func fetchHome() async throws -> HomeBundle { - let dto: HomeResponseDTO = try await provider.request(.home) - - guard let data = dto.data else { - let message = dto.error?.message ?? "홈 데이터 응답이 비어 있습니다" - Log.error("[HomeRepositoryImpl] empty home payload: \(message)") - throw AuthError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Home/Sources/Service/HomeService.swift b/Projects/Data/Home/Sources/Service/HomeService.swift deleted file mode 100644 index dc2dc620..00000000 --- a/Projects/Data/Home/Sources/Service/HomeService.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// HomeService.swift -// Service -// -// Created by Wonji Suh on 5/16/26. -// - -import Foundation - -import API -import NetworkHeader - - -public enum HomeService { - case home -} - -extension HomeService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .home } - - public var urlPath: String { - switch self { - case .home: - return HomeAPI.home.description - } - } - - - public var method: HTTPMethod { - switch self { - case .home: - return .get - } - } - - public var parameters: [String: Any]? { nil } - - public var headers: [String: String]? { - return APIHeader.baseHeader // 인증 헤더 포함 (액세스 토큰) - } -} diff --git a/Projects/Data/Home/Tests/TestSupport.swift b/Projects/Data/Home/Tests/TestSupport.swift deleted file mode 100644 index 3c34b06e..00000000 --- a/Projects/Data/Home/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// HomeDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Model/Project.swift b/Projects/Data/Model/Project.swift deleted file mode 100644 index 42c73db6..00000000 --- a/Projects/Data/Model/Project.swift +++ /dev/null @@ -1,21 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Model"), - bundleId: .appBundleID(name: ".Model"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .Domain(.Battle, .interface), - .Domain(.Home, .interface), - .Domain(implements: .Entity), - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Data/Model/Sources/Common/BaseDataDTO.swift b/Projects/Data/Model/Sources/Common/BaseDataDTO.swift deleted file mode 100644 index ffcda194..00000000 --- a/Projects/Data/Model/Sources/Common/BaseDataDTO.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// 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 deleted file mode 100644 index 81bc8b30..00000000 --- a/Projects/Data/Model/Sources/Common/BaseResponseDTO.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// 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/Model/Sources/Common/ServerNaiveDateParser.swift b/Projects/Data/Model/Sources/Common/ServerNaiveDateParser.swift deleted file mode 100644 index e27d2060..00000000 --- a/Projects/Data/Model/Sources/Common/ServerNaiveDateParser.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ServerNaiveDateParser.swift -// Model -// - -import Foundation - -public enum ServerNaiveDateParser { - private static let formats = [ - "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", - "yyyy-MM-dd'T'HH:mm:ss", - ] - - public static func date(from value: String) -> Date? { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") - for format in formats { - formatter.dateFormat = format - if let date = formatter.date(from: value) { return date } - } - return nil - } -} diff --git a/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift b/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift deleted file mode 100644 index d84df7d5..00000000 --- a/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// PerspectiveCommentDataDTO+.swift -// Model -// - -import CommentDomainInterface -import Entity -import Foundation - -public extension PerspectiveCommentPageDataDTO { - func toDomain() -> PerspectiveCommentPage { - PerspectiveCommentPage( - items: items.map { $0.toDomain() }, - nextCursor: nextCursor, - hasNext: hasNext - ) - } -} - -public extension PerspectiveCommentDTO { - func toDomain() -> PerspectiveComment { - PerspectiveComment( - commentId: commentId, - user: user.toDomain(), - stance: stance ?? "", - content: content, - likeCount: likeCount, - isLiked: isLiked ?? false, - isMine: isMine ?? false, - createdAt: createdAt.flatMap(PerspectiveDateParser.parse) - ) - } -} - -public extension PerspectiveCommentUserDTO { - func toDomain() -> PerspectiveCommentUser { - PerspectiveCommentUser( - userTag: userTag ?? "", - nickname: nickname ?? "익명", - characterType: characterType ?? "", - characterImageUrl: characterImageUrl - ) - } -} - -public extension PerspectiveCommentMutationDataDTO { - func toDomain() -> PerspectiveCommentMutationResult { - PerspectiveCommentMutationResult( - commentId: commentId, - content: content, - updatedAt: updatedAt.flatMap(PerspectiveDateParser.parse) - ) - } -} - -/// 관점/댓글 서버 시간 문자열 파서. -/// 서버가 `2026-05-22T13:28:16.697Z`(타임존 포함) 또는 -/// `2026-05-22T13:28:16`(타임존 없는 LocalDateTime) 양쪽 모두 보낼 수 있어 -/// ISO8601 우선 시도 후 타임존 없는 포맷까지 폴백한다. -/// (기존엔 타임존 없는 문자열이 nil 로 파싱돼 모든 댓글이 "방금 전"으로 표시되던 버그) -enum PerspectiveDateParser { - static func parse(_ value: String) -> Date? { - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = isoFormatter.date(from: value) { return date } - isoFormatter.formatOptions = [.withInternetDateTime] - if let date = isoFormatter.date(from: value) { return date } - - // 타임존이 없는 LocalDateTime 폴백 — 서버 기준시(KST)로 해석. - let fallback = DateFormatter() - fallback.locale = Locale(identifier: "en_US_POSIX") - fallback.timeZone = TimeZone(identifier: "Asia/Seoul") - for format in ["yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss"] { - fallback.dateFormat = format - if let date = fallback.date(from: value) { return date } - } - return nil - } -} diff --git a/Projects/Data/Notification/Project.swift b/Projects/Data/Notification/Project.swift deleted file mode 100644 index 77837f3c..00000000 --- a/Projects/Data/Notification/Project.swift +++ /dev/null @@ -1,23 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "NotificationData"), - bundleId: .appBundleID(name: ".NotificationData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Notification, .interface), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Notification/Sources/Repository/NotificationRepositoryImpl.swift b/Projects/Data/Notification/Sources/Repository/NotificationRepositoryImpl.swift deleted file mode 100644 index 1fec5732..00000000 --- a/Projects/Data/Notification/Sources/Repository/NotificationRepositoryImpl.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// NotificationRepositoryImpl.swift -// Repository -// - -import Foundation - -import Model -import NotificationDomainInterface -import Repository - -import LogMacro - -public final class NotificationRepositoryImpl: NotificationInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func fetchNotifications( - category: NotificationCategory, - page: Int, - size: Int - ) async throws -> NotificationPage { - let dto: NotificationResponseDTO = try await provider.request( - .list( - query: NotificationsQueryRequest( - category: category.rawValue, - page: page, - size: size - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "알림 응답이 비어 있습니다" - Log.error("[NotificationRepositoryImpl] empty notifications payload: \(message)") - throw NotificationError.backendError(message) - } - - return data.toDomain() - } - - public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { - let dto: NotificationDetailResponseDTO = try await provider.request( - .detail(notificationId: notificationId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "알림 상세 응답이 비어 있습니다" - Log.error("[NotificationRepositoryImpl] empty notification detail payload: \(message)") - throw NotificationError.backendError(message) - } - - return data.toDomain() - } - - public func hasUnreadNotifications() async throws -> Bool { - let dto: NotificationUnreadResponseDTO = try await provider.request(.unread) - return dto.data?.hasUnread ?? false - } - - public func markAsRead(notificationId: Int) async throws { - let _: BaseResponseDTO = try await provider.request( - .read(notificationId: notificationId) - ) - } - - /// PATCH /read-all — 서버가 처리 후 최신 미읽음 여부(`data.hasUnread`)를 반환한다. 그 값을 그대로 쓴다. - public func markAllAsRead() async throws -> Bool { - let dto: NotificationUnreadResponseDTO = try await provider.request(.readAll) - return dto.data?.hasUnread ?? false - } -} diff --git a/Projects/Data/Notification/Tests/TestSupport.swift b/Projects/Data/Notification/Tests/TestSupport.swift deleted file mode 100644 index 00726c55..00000000 --- a/Projects/Data/Notification/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// NotificationDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Perspective/Project.swift b/Projects/Data/Perspective/Project.swift deleted file mode 100644 index 2ee1c2bb..00000000 --- a/Projects/Data/Perspective/Project.swift +++ /dev/null @@ -1,26 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "PerspectiveData"), - bundleId: .appBundleID(name: ".PerspectiveData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Perspective, .interface), - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.weaveDI, - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Perspective/Sources/Repository/PerspectiveRepositoryImpl.swift b/Projects/Data/Perspective/Sources/Repository/PerspectiveRepositoryImpl.swift deleted file mode 100644 index 2550f6bd..00000000 --- a/Projects/Data/Perspective/Sources/Repository/PerspectiveRepositoryImpl.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// PerspectiveRepositoryImpl.swift -// Repository -// - -import Foundation - -import CommentDomainInterface -import CommonDomainInterface -import Model -import PerspectiveDomainInterface -import Repository - -import LogMacro - - -public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { - let dto: PerspectiveDetailResponseDTO = try await provider.request( - .detail(perspectiveId: perspectiveId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "perspective 상세 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty detail payload: \(message)") - throw PerspectiveError.backendError(message) - } - - return data.toDomain() - } - - public func fetchLabeledComments( - perspectiveId: Int, - cursor: String?, - size: Int? - ) async throws -> PerspectiveCommentPage { - let dto: PerspectiveCommentPageResponseDTO = try await provider.request( - .listLabeledComments(perspectiveId: perspectiveId, cursor: cursor, size: size) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "대댓글 목록 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty list payload: \(message)") - throw CommentError.backendError(message) - } - - return data.toDomain() - } - - public func createComment( - perspectiveId: Int, - content: String - ) async throws -> PerspectiveCommentMutationResult { - let dto: PerspectiveCommentMutationResponseDTO = try await provider.request( - .createComment(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "대댓글 작성 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty create payload: \(message)") - throw CommentError.backendError(message) - } - - return data.toDomain() - } - - public func updateComment( - perspectiveId: Int, - commentId: Int, - content: String - ) async throws -> PerspectiveCommentMutationResult { - let dto: PerspectiveCommentMutationResponseDTO = try await provider.request( - .updateComment( - perspectiveId: perspectiveId, - commentId: commentId, - body: PerspectiveCommentBody(content: content) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "대댓글 수정 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty update payload: \(message)") - throw CommentError.backendError(message) - } - - return data.toDomain() - } - - public func deleteComment( - perspectiveId: Int, - commentId: Int - ) async throws { - let dto: BaseResponseDTO = try await provider.request( - .deleteComment(perspectiveId: perspectiveId, commentId: commentId) - ) - - if dto.statusCode >= 400 { - let message = dto.error?.message ?? "대댓글 삭제 실패" - throw CommentError.backendError(message) - } - } - - public func updatePerspective(perspectiveId: Int, content: String) async throws { - let dto: BaseResponseDTO = try await provider.request( - .updatePerspective(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)) - ) - - if dto.statusCode >= 400 { - let message = dto.error?.message ?? "perspective 수정 실패" - throw PerspectiveError.backendError(message) - } - } - - public func deletePerspective(perspectiveId: Int) async throws { - let dto: BaseResponseDTO = try await provider.request( - .deletePerspective(perspectiveId: perspectiveId) - ) - - if dto.statusCode >= 400 { - let message = dto.error?.message ?? "perspective 삭제 실패" - throw PerspectiveError.backendError(message) - } - } - - public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { - let dto: CommentLikeResponseDTO = try await provider.request( - .likePerspective(perspectiveId: perspectiveId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "관점 좋아요 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty like payload: \(message)") - throw CommentError.backendError(message) - } - - return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: true) - } - - public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { - let dto: CommentLikeResponseDTO = try await provider.request( - .unlikePerspective(perspectiveId: perspectiveId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "관점 좋아요 취소 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty unlike payload: \(message)") - throw CommentError.backendError(message) - } - - return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: false) - } - - public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { - let dto: CommentLikeResponseDTO = try await provider.request( - .fetchPerspectiveLikes(perspectiveId: perspectiveId) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "관점 좋아요 수 응답이 비어 있습니다" - Log.error("[PerspectiveRepositoryImpl] empty like payload: \(message)") - throw CommentError.backendError(message) - } - - return data.toDomain() - } - - public func reportPerspective(perspectiveId: Int) async throws { - let dto: BaseResponseDTO = try await provider.request( - .reportPerspective(perspectiveId: perspectiveId) - ) - - if dto.statusCode >= 400 { - let message = dto.error?.message ?? "관점 신고 실패" - throw CommentError.backendError(message) - } - } - - public func reportComment(perspectiveId: Int, commentId: Int) async throws { - let dto: BaseResponseDTO = try await provider.request( - .reportComment(perspectiveId: perspectiveId, commentId: commentId) - ) - - if dto.statusCode >= 400 { - let message = dto.error?.message ?? "댓글 신고 실패" - throw CommentError.backendError(message) - } - } -} - -public struct EmptyDTO: Decodable {} diff --git a/Projects/Data/Perspective/Tests/TestSupport.swift b/Projects/Data/Perspective/Tests/TestSupport.swift deleted file mode 100644 index 275c5faa..00000000 --- a/Projects/Data/Perspective/Tests/TestSupport.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// TestSupport.swift -// PerspectiveDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Profile/Project.swift b/Projects/Data/Profile/Project.swift deleted file mode 100644 index d090ab92..00000000 --- a/Projects/Data/Profile/Project.swift +++ /dev/null @@ -1,23 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "ProfileData"), - bundleId: .appBundleID(name: ".ProfileData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Profile, .interface), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Service), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Profile/Sources/Model/Mapper/CreditHistoryDataDTO+.swift b/Projects/Data/Profile/Sources/Model/Mapper/CreditHistoryDataDTO+.swift deleted file mode 100644 index 9204a5ec..00000000 --- a/Projects/Data/Profile/Sources/Model/Mapper/CreditHistoryDataDTO+.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// CreditHistoryDataDTO+.swift -// Model -// - -import Foundation -import Model -import ProfileDomainInterface - -public extension CreditHistoryDataDTO { - func toDomain() -> CreditHistoryPage { - CreditHistoryPage( - items: (items ?? []).map { $0.toDomain() }, - nextOffset: nextOffset ?? 0, - hasNext: hasNext ?? false - ) - } -} - -public extension CreditHistoryItemDTO { - func toDomain() -> CreditHistoryItem { - CreditHistoryItem( - id: id ?? 0, - creditType: creditType ?? "", - amount: amount ?? 0, - referenceId: referenceId, - createdAt: createdAt.flatMap(Self.parseISO8601) - ) - } - - private static func parseISO8601(_ value: String) -> Date? { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: value) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: value) { return date } - // 타임존 없이 내려오는 서버 시각(예: "2026-07-12T21:26:26.921763") — KST 벽시계로 해석. - return ServerNaiveDateParser.date(from: value) - } -} diff --git a/Projects/Data/Profile/Sources/Repository/ProfileRepositoryImpl.swift b/Projects/Data/Profile/Sources/Repository/ProfileRepositoryImpl.swift deleted file mode 100644 index 144a4100..00000000 --- a/Projects/Data/Profile/Sources/Repository/ProfileRepositoryImpl.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// ProfileRepositoryImpl.swift -// Repository -// - -import Foundation - -import ProfileDomainInterface -import Repository - -import LogMacro - - -public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func fetchMyPage() async throws -> MyPage { - let dto: MyPageResponseDTO = try await provider.request(.mypage) - - guard let data = dto.data else { - let message = dto.error?.message ?? "마이페이지 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty myPage payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } - - public func fetchRecap() async throws -> PhilosopherRecap { - let dto: RecapResponseDTO = try await provider.request(.recap) - - // 배틀 5개 미만 사용자는 서버가 data: nil (200) 로 응답 → 잠금 상태. - // 에러로 던지지 않고 빈 recap(totalParticipation 0 → isLocked)으로 반환. - guard let data = dto.data else { - Log.info("[ProfileRepositoryImpl] recap 빈 응답 → 잠금 화면 반환") - return .empty - } - - return data.toDomain() - } - - public func fetchCreditHistory( - offset: Int?, - size: Int - ) async throws -> CreditHistoryPage { - let dto: CreditHistoryResponseDTO = try await provider.request( - .creditsHistory(query: CreditHistoryQueryRequest(offset: offset, size: size)) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "크레딧 내역 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty creditHistory payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } - - public func fetchBattleRecords( - offset: Int, - size: Int, - voteSide: BattleVoteSide? - ) async throws -> BattleRecordPage { - let dto: BattleRecordResponseDTO = try await provider.request( - .battleRecords( - query: BattleRecordsQueryRequest( - offset: offset, - size: size, - voteSide: voteSide.flatMap { $0 == .unknown ? nil : $0.rawValue } - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "배틀 기록 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty battleRecords payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } - - public func fetchContentActivities( - offset: Int, - size: Int, - activityType: ContentActivityType? - ) async throws -> ContentActivityPage { - let dto: ContentActivityResponseDTO = try await provider.request( - .contentActivities( - query: ContentActivitiesQueryRequest( - offset: offset, - size: size, - activityType: activityType.flatMap(\.rawValue) - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "콘텐츠 활동 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty contentActivities payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } - - public func fetchNotificationSettings() async throws -> NotificationSettings { - let dto: NotificationSettingsResponseDTO = try await provider.request(.notificationSettings) - - guard let data = dto.data else { - let message = dto.error?.message ?? "알림 설정 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty notificationSettings payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } - - public func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings { - let dto: NotificationSettingsResponseDTO = try await provider.request( - .updateNotificationSettings( - body: NotificationSettingsRequest( - newBattleEnabled: settings.newBattleEnabled, - battleResultEnabled: settings.battleResultEnabled, - commentReplyEnabled: settings.commentReplyEnabled, - newCommentEnabled: settings.newCommentEnabled, - contentLikeEnabled: settings.contentLikeEnabled, - marketingEventEnabled: settings.marketingEventEnabled - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "알림 설정 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty updateNotificationSettings payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } - - public func updateProfile( - nickname: String, - characterType: String - ) async throws -> UpdatedProfile { - let dto: ProfileUpdateResponseDTO = try await provider.request( - .updateProfile( - body: ProfileUpdateRequest( - nickname: nickname, - characterType: characterType - ) - ) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "프로필 수정 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty updateProfile payload: \(message)") - throw ProfileError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Profile/Tests/TestSupport.swift b/Projects/Data/Profile/Tests/TestSupport.swift deleted file mode 100644 index 8e9e2f2f..00000000 --- a/Projects/Data/Profile/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// ProfileDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Repository/Project.swift b/Projects/Data/Repository/Project.swift deleted file mode 100644 index e9051869..00000000 --- a/Projects/Data/Repository/Project.swift +++ /dev/null @@ -1,30 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Repository"), - bundleId: .appBundleID(name: ".Repository"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .Domain(.Home, .interface), - .Network(implements: .Networking), - .Network(implements: .NetworkHeader), - .Data(implements: .Service), - .Data(implements: .Model), - .Domain(implements: .DomainInterface), - .Domain(.Auth, .interface), - .SPM.alamofire, - .SPM.composableArchitecture, - .SPM.weaveDI, - .SPM.logMarco, - .SPM.mixpanel, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Repository/Sources/Auth/Interceptor/AuthInterceptor.swift b/Projects/Data/Repository/Sources/Auth/Interceptor/AuthInterceptor.swift deleted file mode 100644 index d089f9de..00000000 --- a/Projects/Data/Repository/Sources/Auth/Interceptor/AuthInterceptor.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// AuthInterceptor.swift -// Repository -// -// Created by Wonji Suh on 5/14/26. -// - -import Alamofire -import AuthDomainInterface -import ComposableArchitecture -import Dependencies -import DomainInterface -import Entity -import Foundation -import LogMacro -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 afError = error.asAFError, - case let .responseValidationFailed(reason) = afError, - case let .unacceptableStatusCode(code) = reason, - code == 401 - { - return true - } - - 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/Interceptor/SessionInvalidationMonitor.swift b/Projects/Data/Repository/Sources/Auth/Interceptor/SessionInvalidationMonitor.swift deleted file mode 100644 index 2d6c3b96..00000000 --- a/Projects/Data/Repository/Sources/Auth/Interceptor/SessionInvalidationMonitor.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// SessionInvalidationMonitor.swift -// Repository -// - -import Alamofire -import Dependencies -import DomainInterface -import Foundation -import LogMacro - -struct SessionInvalidationMonitor: EventMonitor { - let queue = DispatchQueue(label: "store.picke.network.session-invalidation") - - /// 강제 로그아웃을 유발하는 서버 에러 코드. - /// - USER_404: 존재하지 않는 사용자 - /// - AUTH_401: 인증 필요 (AuthInterceptor refresh+retry 후에도 최종 401 이면 세션 만료로 간주) - private static let invalidSessionCodes: Set = ["USER_404", "AUTH_401"] - - private struct ErrorEnvelope: Decodable { - struct APIError: Decodable { let code: String } - let error: APIError? - } - - func request( - _: DataRequest, - didParseResponse response: DataResponse - ) { - guard - let data = response.data, - let envelope = try? JSONDecoder().decode(ErrorEnvelope.self, from: data), - let code = envelope.error?.code, - Self.invalidSessionCodes.contains(code) - else { return } - - Log.error("🚪 \(code) 감지 → 강제 로그아웃") - forceLogout() - } - - private func forceLogout() { - @Dependency(\.keychainManager) var keychainManager - keychainManager.clear() - - _Concurrency.Task { @MainActor in - AuthSessionManager.shared.credential = nil - OptimizedSessionManager.shared.credential = nil - - NotificationCenter.default.post( - name: .refreshTokenExpired, - object: nil, - userInfo: ["reason": "user_not_found"] - ) - } - } -} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift deleted file mode 100644 index 9f4e0060..00000000 --- a/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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 deleted file mode 100644 index 424a5457..00000000 --- a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// AuthSessionManager.swift -// Repository -// -// Created by Wonji Suh on 5/14/26. -// - -import Alamofire -import AuthDomainInterface -import DomainInterface -import Entity -import Foundation -import UIKit -import WeaveDI - -public final class AuthSessionManager { - public 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() - } - - public func updateCredential(with tokens: AuthTokens) { - credential = AccessTokenCredential.make( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken - ) - } - - public 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/OptimizedSessionManager.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/OptimizedSessionManager.swift deleted file mode 100644 index f5b8077c..00000000 --- a/Projects/Data/Repository/Sources/Auth/RefreshToken/OptimizedSessionManager.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// OptimizedSessionManager.swift -// Repository -// -// Created by Wonji Suh on 5/14/26. -// - -import Alamofire -import AuthDomainInterface -import DomainInterface -import Entity -import Foundation -import WeaveDI - -/// 네트워킹 성능 최적화된 세션 매니저 -public final class OptimizedSessionManager { - public static let shared = OptimizedSessionManager() - - @Dependency(\.keychainManager) var keychainManager - - var credential: AccessTokenCredential? - /// 인증 세션(인터셉터·이벤트 모니터 부착). 대부분의 요청에 사용. - let session: Session - /// 비인증 세션(로그인/토큰 재발급 등 토큰이 아직 없는 요청). 인터셉터 없음. - let plainSession: Session - - private init() { - // 세션 조립은 SessionFactory 가 담당(config·인터셉터·이벤트 모니터). 동작 보존. - session = SessionFactory.authenticated() - plainSession = SessionFactory.plain() - - setupInitialCredential() - } - - public func updateCredential(with tokens: AuthTokens) { - credential = AccessTokenCredential.make( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken - ) - } - - public 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/Device/DeviceRepositoryImpl.swift b/Projects/Data/Repository/Sources/Device/DeviceRepositoryImpl.swift deleted file mode 100644 index b31cd800..00000000 --- a/Projects/Data/Repository/Sources/Device/DeviceRepositoryImpl.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// DeviceRepositoryImpl.swift -// Repository -// - -import Foundation - -import DomainInterface -import Entity -import Model -import Service - -import LogMacro - - -public final class DeviceRepositoryImpl: DeviceInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func registerDevice(fcmToken: String, platform: DevicePlatform) async throws { - let _: BaseResponseDTO = try await provider.request( - .register( - body: DeviceRegisterRequest( - fcmToken: fcmToken, - platform: platform.rawValue - ) - ) - ) - } - - public func unregisterDevice(fcmToken: String) async throws { - let _: BaseResponseDTO = try await provider.request( - .unregister(fcmToken: fcmToken) - ) - } -} diff --git a/Projects/Data/Repository/Sources/Network/AlamofireNetworkProvider.swift b/Projects/Data/Repository/Sources/Network/AlamofireNetworkProvider.swift deleted file mode 100644 index a675a194..00000000 --- a/Projects/Data/Repository/Sources/Network/AlamofireNetworkProvider.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// AlamofireNetworkProvider.swift -// Repository -// - -import Foundation - -import Alamofire -import NetworkHeader - -/// `NetworkProviding` 의 Alamofire 백엔드. -/// - `.authorized`: 인증 인터셉터가 얹힌 세션(대부분의 요청). -/// - `.default`: 인증 없는 세션(로그인/토큰 재발급 등 토큰이 아직 없는 요청). -public struct AlamofireNetworkProvider: NetworkProviding { - private let session: Session - private let decoder: JSONDecoder - - /// - Parameter session: 기본(nil)은 인증 세션(동작 보존). - public init( - session: Session? = nil, - decoder: JSONDecoder = JSONDecoder() - ) { - self.session = session ?? OptimizedSessionManager.shared.session - self.decoder = decoder - } - - public func request(_ target: Target) async throws -> D { - let request = try target.asURLRequest() - return try await session - .request(request) - .validate() - .serializingDecodable(D.self, decoder: decoder) - .value - } - - public func requestResponse(_ target: Target) async throws -> PickeResponse { - let request = try target.asURLRequest() - let dataTask = session.request(request).serializingData() - let response = await dataTask.response - let data = try response.result.get() - return PickeResponse( - statusCode: response.response?.statusCode ?? 0, - data: data - ) - } -} - -public extension AlamofireNetworkProvider { - /// 인증 세션(인터셉터 부착) 기반 provider. 기존 `MoyaProvider.authorized` 대체. - static var authorized: AlamofireNetworkProvider { - AlamofireNetworkProvider(session: OptimizedSessionManager.shared.session) - } - - /// 인증 없는 세션 기반 provider. 기존 `MoyaProvider.default` 대체. - static var `default`: AlamofireNetworkProvider { - AlamofireNetworkProvider(session: OptimizedSessionManager.shared.plainSession) - } -} diff --git a/Projects/Data/Repository/Sources/Network/NetworkProviding.swift b/Projects/Data/Repository/Sources/Network/NetworkProviding.swift deleted file mode 100644 index 2fc1f8b9..00000000 --- a/Projects/Data/Repository/Sources/Network/NetworkProviding.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// NetworkProviding.swift -// Repository -// - -import Foundation - -import NetworkHeader - -/// Moya `Response` 를 대체하는 경량 응답 래퍼(디코딩 전 원시 응답). -/// `requestResponse` 사용처(로그아웃/탈퇴)가 `statusCode`/`data` 만 참조하므로 그 둘만 담는다. -public struct PickeResponse: Sendable { - public let statusCode: Int - public let data: Data - - public init(statusCode: Int, data: Data) { - self.statusCode = statusCode - self.data = data - } -} - -/// feature Service(`PickeTargetType`) 단위의 네트워크 요청 실행 추상. -public protocol NetworkProviding { - associatedtype Target: PickeTargetType - - /// 응답을 `Decodable` 로 디코딩해 반환. - func request(_ target: Target) async throws -> D - - /// 원시 응답 반환(디코딩 전). - func requestResponse(_ target: Target) async throws -> PickeResponse -} diff --git a/Projects/Data/Repository/Sources/Network/SessionFactory.swift b/Projects/Data/Repository/Sources/Network/SessionFactory.swift deleted file mode 100644 index cf45d92a..00000000 --- a/Projects/Data/Repository/Sources/Network/SessionFactory.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// SessionFactory.swift -// Repository -// - -import Alamofire -import Foundation - -/// 인증(authenticated) / 비인증(plain) 세션 조립 팩토리. -enum SessionFactory { - /// 인증 세션. `AuthInterceptor`(401 자동 refresh·retry)를 얹는다. 대부분의 요청에 사용. - static func authenticated() -> Session { - Session( - configuration: configuration(), - interceptor: AuthInterceptor(), - eventMonitors: monitors() - ) - } - - /// 비인증 세션. 로그인/토큰 재발급처럼 아직 토큰이 없는 요청에 사용(인터셉터 없음). - static func plain() -> Session { - Session( - configuration: configuration(), - eventMonitors: monitors() - ) - } -} - -private extension SessionFactory { - /// 성능 최적화된 URLSession 설정(커넥션 풀 / 캐시 / keep-alive). - static func configuration() -> URLSessionConfiguration { - 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", - ] - - return configuration - } - - /// 두 세션 공통 이벤트 모니터. 요청/응답 로깅 + USER_404/AUTH_401 세션 무효화. - static func monitors() -> [any EventMonitor] { - [PickeEventMonitor(), SessionInvalidationMonitor()] - } -} diff --git a/Projects/Data/Repository/Tests/Sources/Test.swift b/Projects/Data/Repository/Tests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Data/Repository/Tests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Data/Search/Project.swift b/Projects/Data/Search/Project.swift deleted file mode 100644 index e49d5ea8..00000000 --- a/Projects/Data/Search/Project.swift +++ /dev/null @@ -1,25 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "SearchData"), - bundleId: .appBundleID(name: ".SearchData"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Search, .interface), - .Domain(.Common, .interface), - .Domain(.Home, .interface), - .Domain(implements: .Entity), - .Data(implements: .API), - .Data(implements: .Model), - .Data(implements: .Repository), - .Network(implements: .NetworkHeader), - .SPM.logMarco, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Data/Search/Sources/SearchRepositoryImpl.swift b/Projects/Data/Search/Sources/SearchRepositoryImpl.swift deleted file mode 100644 index c4fc429a..00000000 --- a/Projects/Data/Search/Sources/SearchRepositoryImpl.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// SearchRepositoryImpl.swift -// Repository -// - -import Foundation - -import Entity -import HomeDomainInterface -import Model -import Repository -import SearchDomainInterface - -import LogMacro - - -public final class SearchRepositoryImpl: SearchInterface, @unchecked Sendable { - private let provider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - } - - public func searchBattles( - category: String?, - sort: String?, - offset: Int?, - size: Int? - ) async throws -> ExploreItemPage { - let dto: SearchBattlePageResponseDTO = try await provider.request( - .battles(category: category, sort: sort, offset: offset, size: size) - ) - - guard let data = dto.data else { - let message = dto.error?.message ?? "배틀 검색 응답이 비어 있습니다" - Log.error("[SearchRepositoryImpl] empty searchBattles payload: \(message)") - throw BattleError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Search/Sources/SearchService.swift b/Projects/Data/Search/Sources/SearchService.swift deleted file mode 100644 index 3cbbf057..00000000 --- a/Projects/Data/Search/Sources/SearchService.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// SearchService.swift -// Service -// - -import Foundation - -import API -import NetworkHeader - - -public enum SearchService { - case battles(category: String?, sort: String?, offset: Int?, size: Int?) -} - -extension SearchService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .search } - - public var urlPath: String { - switch self { - case .battles: - return SearchAPI.battles.description - } - } - - - public var method: HTTPMethod { - switch self { - case .battles: - return .get - } - } - - public var parameters: [String: Any]? { - switch self { - case let .battles(category, sort, offset, size): - var query: [String: Any] = [:] - if let category, !category.isEmpty { query["category"] = category } - if let sort, !sort.isEmpty { query["sort"] = sort } - if let offset { query["offset"] = offset } - if let size { query["size"] = size } - return query.isEmpty ? nil : query - } - } - - public var headers: [String: String]? { - return APIHeader.baseHeader - } -} diff --git a/Projects/Data/Search/Tests/TestSupport.swift b/Projects/Data/Search/Tests/TestSupport.swift deleted file mode 100644 index 1a0072c5..00000000 --- a/Projects/Data/Search/Tests/TestSupport.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TestSupport.swift -// SearchDataTests -// - -import Foundation - -import NetworkHeader -import Repository - -/// 고정 데이터를 그대로 디코딩해 반환하는 스텁 provider. -struct StubNetworkProvider: NetworkProviding { - let stubData: Data - var statusCode: Int = 200 - - func request(_: Target) async throws -> D { - try JSONDecoder().decode(D.self, from: stubData) - } - - func requestResponse(_: Target) async throws -> PickeResponse { - PickeResponse(statusCode: statusCode, data: stubData) - } -} - -/// 항상 에러를 던지는 스텁 provider. -struct ThrowingStubNetworkProvider: NetworkProviding { - struct StubError: Error {} - - func request(_: Target) async throws -> D { - throw StubError() - } - - func requestResponse(_: Target) async throws -> PickeResponse { - throw StubError() - } -} diff --git a/Projects/Data/Service/Project.swift b/Projects/Data/Service/Project.swift deleted file mode 100644 index dc7b21bd..00000000 --- a/Projects/Data/Service/Project.swift +++ /dev/null @@ -1,19 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Service"), - bundleId: .appBundleID(name: ".Service"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Data(implements: .API), - .Domain(implements: .Entity), - .Network(implements: .NetworkHeader), - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Data/Service/ServiceTests/Sources/Test.swift b/Projects/Data/Service/ServiceTests/Sources/Test.swift deleted file mode 100644 index 88831a39..00000000 --- a/Projects/Data/Service/ServiceTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Data/Service/Sources/Device/DeviceService.swift b/Projects/Data/Service/Sources/Device/DeviceService.swift deleted file mode 100644 index 2dc09e5c..00000000 --- a/Projects/Data/Service/Sources/Device/DeviceService.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// DeviceService.swift -// Service -// - -import Foundation - -import API -import NetworkHeader - -public enum DeviceService { - /// POST /api/v1/devices - case register(body: DeviceRegisterRequest) - /// DELETE /api/v1/devices?fcmToken=... - case unregister(fcmToken: String) -} - -extension DeviceService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .device } - - public var urlPath: String { "" } - - public var method: HTTPMethod { - switch self { - case .register: - return .post - case .unregister: - return .delete - } - } - - public var parameters: [String: Any]? { - switch self { - case let .register(body): - return body.toDictionary - case let .unregister(fcmToken): - return ["fcmToken": fcmToken] - } - } - - /// unregister 는 쿼리 파라미터, register 는 JSON 바디로 인코딩. - public var parameterEncoding: ParameterEncoding { - switch self { - case .register: - return JSONEncoding.default - case .unregister: - return URLEncoding.queryString - } - } - - public var headers: [String: String]? { - APIHeader.baseHeader - } -} diff --git a/Projects/Domain/AppUpdateDomain/Interface/Sources/AppUpdateInterface.swift b/Projects/Domain/AppUpdateDomain/Interface/Sources/AppUpdateInterface.swift new file mode 100644 index 00000000..e6c3b4a6 --- /dev/null +++ b/Projects/Domain/AppUpdateDomain/Interface/Sources/AppUpdateInterface.swift @@ -0,0 +1,23 @@ +// +// AppUpdateInterface.swift +// AppUpdateDomainInterface +// + +import Foundation + +import ComposableArchitecture + +public protocol AppUpdateInterface: Sendable { + func checkForUpdate() async throws -> AppUpdateInfo +} + +public enum AppUpdateRepositoryDependency: TestDependencyKey { + public static var testValue: AppUpdateInterface { MockAppUpdateRepository() } +} + +public extension DependencyValues { + var appUpdateRepository: AppUpdateInterface { + get { self[AppUpdateRepositoryDependency.self] } + set { self[AppUpdateRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/AppUpdateDomain/Interface/Sources/AppUpdateUseCaseInterface.swift b/Projects/Domain/AppUpdateDomain/Interface/Sources/AppUpdateUseCaseInterface.swift new file mode 100644 index 00000000..c27872fc --- /dev/null +++ b/Projects/Domain/AppUpdateDomain/Interface/Sources/AppUpdateUseCaseInterface.swift @@ -0,0 +1,23 @@ +// +// AppUpdateUseCaseInterface.swift +// AppUpdateDomainInterface +// + +import Foundation + +import ComposableArchitecture + +public protocol AppUpdateUseCaseInterface: Sendable { + func checkForUpdate() async throws -> AppUpdateInfo? +} + +public enum AppUpdateUseCaseDependency: TestDependencyKey { + public static var testValue: AppUpdateUseCaseInterface { MockAppUpdateUseCase() } +} + +public extension DependencyValues { + var appUpdateUseCase: AppUpdateUseCaseInterface { + get { self[AppUpdateUseCaseDependency.self] } + set { self[AppUpdateUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/AppUpdate/Default/DefaultAppUpdateRepositoryImpl.swift b/Projects/Domain/AppUpdateDomain/Interface/Sources/Default/MockAppUpdateRepository.swift similarity index 73% rename from Projects/Domain/DomainInterface/Sources/AppUpdate/Default/DefaultAppUpdateRepositoryImpl.swift rename to Projects/Domain/AppUpdateDomain/Interface/Sources/Default/MockAppUpdateRepository.swift index e359805f..303800fa 100644 --- a/Projects/Domain/DomainInterface/Sources/AppUpdate/Default/DefaultAppUpdateRepositoryImpl.swift +++ b/Projects/Domain/AppUpdateDomain/Interface/Sources/Default/MockAppUpdateRepository.swift @@ -1,13 +1,13 @@ // -// DefaultAppUpdateRepositoryImpl.swift -// DomainInterface +// MockAppUpdateRepository.swift +// AppUpdateDomain // import Foundation -import Entity +import AppUpdateDomainInterface -public final class DefaultAppUpdateRepositoryImpl: AppUpdateInterface { +public final class MockAppUpdateRepository: AppUpdateInterface { public init() {} public func checkForUpdate() async throws -> AppUpdateInfo { diff --git a/Projects/Domain/AppUpdateDomain/Interface/Sources/Default/MockAppUpdateUseCase.swift b/Projects/Domain/AppUpdateDomain/Interface/Sources/Default/MockAppUpdateUseCase.swift new file mode 100644 index 00000000..612ec642 --- /dev/null +++ b/Projects/Domain/AppUpdateDomain/Interface/Sources/Default/MockAppUpdateUseCase.swift @@ -0,0 +1,16 @@ +// +// MockAppUpdateUseCase.swift +// AppUpdateDomain +// + +import Foundation + +import AppUpdateDomainInterface + +public struct MockAppUpdateUseCase: AppUpdateUseCaseInterface { + public init() {} + + public func checkForUpdate() async throws -> AppUpdateInfo? { + nil + } +} diff --git a/Projects/Domain/Entity/Sources/Error/AppUpdateError.swift b/Projects/Domain/AppUpdateDomain/Interface/Sources/Entity/AppUpdateError.swift similarity index 97% rename from Projects/Domain/Entity/Sources/Error/AppUpdateError.swift rename to Projects/Domain/AppUpdateDomain/Interface/Sources/Entity/AppUpdateError.swift index f7ab2ca4..3d958347 100644 --- a/Projects/Domain/Entity/Sources/Error/AppUpdateError.swift +++ b/Projects/Domain/AppUpdateDomain/Interface/Sources/Entity/AppUpdateError.swift @@ -1,6 +1,6 @@ // // AppUpdateError.swift -// Entity +// AppUpdateDomainInterface // import Foundation diff --git a/Projects/Domain/Entity/Sources/AppUpdate/AppUpdateInfo.swift b/Projects/Domain/AppUpdateDomain/Interface/Sources/Entity/AppUpdateInfo.swift similarity index 95% rename from Projects/Domain/Entity/Sources/AppUpdate/AppUpdateInfo.swift rename to Projects/Domain/AppUpdateDomain/Interface/Sources/Entity/AppUpdateInfo.swift index 80002455..7187c2fe 100644 --- a/Projects/Domain/Entity/Sources/AppUpdate/AppUpdateInfo.swift +++ b/Projects/Domain/AppUpdateDomain/Interface/Sources/Entity/AppUpdateInfo.swift @@ -1,6 +1,6 @@ // // AppUpdateInfo.swift -// Entity +// AppUpdateDomainInterface // import Foundation diff --git a/Projects/Domain/AppUpdateDomain/Project.swift b/Projects/Domain/AppUpdateDomain/Project.swift new file mode 100644 index 00000000..70cf4aae --- /dev/null +++ b/Projects/Domain/AppUpdateDomain/Project.swift @@ -0,0 +1,23 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "AppUpdateDomain", + bundleId: .appBundleID(name: ".AppUpdateDomain"), + product: .framework, + settings: .settings(), + dependencies: [ + .serviceAssembly, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Domain/AppUpdateDomain/Sources/AppUpdateLiveDependencies.swift b/Projects/Domain/AppUpdateDomain/Sources/AppUpdateLiveDependencies.swift new file mode 100644 index 00000000..243ff410 --- /dev/null +++ b/Projects/Domain/AppUpdateDomain/Sources/AppUpdateLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// AppUpdateLiveDependencies.swift +// AppUpdateDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import AppUpdateDomainInterface +import ComposableArchitecture + +extension AppUpdateUseCaseDependency: DependencyKey { + public static var liveValue: AppUpdateUseCaseInterface { AppUpdateUseCaseImpl() } +} + +extension AppUpdateRepositoryDependency: DependencyKey { + public static var liveValue: AppUpdateInterface { AppUpdateRepositoryImpl() } +} diff --git a/Projects/Domain/UseCase/Sources/AppUpdate/AppUpdateUseCaseImpl.swift b/Projects/Domain/AppUpdateDomain/Sources/AppUpdateUseCaseImpl.swift similarity index 88% rename from Projects/Domain/UseCase/Sources/AppUpdate/AppUpdateUseCaseImpl.swift rename to Projects/Domain/AppUpdateDomain/Sources/AppUpdateUseCaseImpl.swift index e0aad24d..96911f5b 100644 --- a/Projects/Domain/UseCase/Sources/AppUpdate/AppUpdateUseCaseImpl.swift +++ b/Projects/Domain/AppUpdateDomain/Sources/AppUpdateUseCaseImpl.swift @@ -1,10 +1,9 @@ // // AppUpdateUseCaseImpl.swift -// UseCase +// AppUpdateDomain // -import DomainInterface -import Entity +import AppUpdateDomainInterface import ComposableArchitecture diff --git a/Projects/Data/Model/Sources/AppUpdate/AppUpdateDTO.swift b/Projects/Domain/AppUpdateDomain/Sources/Model/AppUpdateDTO.swift similarity index 97% rename from Projects/Data/Model/Sources/AppUpdate/AppUpdateDTO.swift rename to Projects/Domain/AppUpdateDomain/Sources/Model/AppUpdateDTO.swift index 7784fabc..cb615767 100644 --- a/Projects/Data/Model/Sources/AppUpdate/AppUpdateDTO.swift +++ b/Projects/Domain/AppUpdateDomain/Sources/Model/AppUpdateDTO.swift @@ -1,6 +1,6 @@ // // AppUpdateDTO.swift -// Model +// AppUpdateDomain // import Foundation diff --git a/Projects/Data/Model/Sources/AppUpdate/Mapper/AppUpdateDTO+.swift b/Projects/Domain/AppUpdateDomain/Sources/Model/Mapper/AppUpdateDTO+.swift similarity index 91% rename from Projects/Data/Model/Sources/AppUpdate/Mapper/AppUpdateDTO+.swift rename to Projects/Domain/AppUpdateDomain/Sources/Model/Mapper/AppUpdateDTO+.swift index b78ade4b..b1688930 100644 --- a/Projects/Data/Model/Sources/AppUpdate/Mapper/AppUpdateDTO+.swift +++ b/Projects/Domain/AppUpdateDomain/Sources/Model/Mapper/AppUpdateDTO+.swift @@ -1,11 +1,11 @@ // // AppUpdateDTO+.swift -// Model +// AppUpdateDomain // import Foundation +import AppUpdateDomainInterface -import Entity public extension AppStoreInfoDTO { func toEntity(currentVersion: String) -> AppUpdateInfo { diff --git a/Projects/Data/Repository/Sources/AppUpdate/AppUpdateRepositoryImpl.swift b/Projects/Domain/AppUpdateDomain/Sources/Repository/AppUpdateRepositoryImpl.swift similarity index 68% rename from Projects/Data/Repository/Sources/AppUpdate/AppUpdateRepositoryImpl.swift rename to Projects/Domain/AppUpdateDomain/Sources/Repository/AppUpdateRepositoryImpl.swift index fa871a76..da51042a 100644 --- a/Projects/Data/Repository/Sources/AppUpdate/AppUpdateRepositoryImpl.swift +++ b/Projects/Domain/AppUpdateDomain/Sources/Repository/AppUpdateRepositoryImpl.swift @@ -1,15 +1,13 @@ // // AppUpdateRepositoryImpl.swift -// Repository +// AppUpdateDomain // import Foundation +import PickeCoreLogger -import DomainInterface -import Entity -import Model - -import LogMacro +import AppUpdateDomainInterface +import PickeNetwork public final class AppUpdateRepositoryImpl: AppUpdateInterface { private let urlSession: URLSession @@ -49,8 +47,15 @@ public final class AppUpdateRepositoryImpl: AppUpdateInterface { let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)" guard let url = URL(string: urlString) else { throw AppUpdateError.invalidBundleId } + let startedAt = Date() do { - let (data, _) = try await urlSession.data(from: url) + let (data, urlResponse) = try await urlSession.data(from: url) + recordTelemetry( + url: url, + response: urlResponse, + startedAt: startedAt, + isSuccess: true + ) let response = try JSONDecoder().decode(AppUpdateResponseDTO.self, from: data) guard let appInfo = response.results.first else { throw AppUpdateError.appNotFound } return appInfo @@ -59,11 +64,35 @@ public final class AppUpdateRepositoryImpl: AppUpdateInterface { } catch let error as AppUpdateError { throw error } catch { - Log.error("[AppUpdate] lookup 실패(\(country)): \(error.localizedDescription)") + recordTelemetry( + url: url, + response: nil, + startedAt: startedAt, + isSuccess: false + ) + PickeLogger.error("[AppUpdate] lookup 실패(\(country)): \(error.localizedDescription)", category: .app) throw AppUpdateError.from(error) } } + private func recordTelemetry( + url: URL, + response: URLResponse?, + startedAt: Date, + isSuccess: Bool + ) { + NetworkTelemetry.shared.record( + NetworkTelemetryEvent( + source: "app_store_lookup", + method: "GET", + url: url, + statusCode: (response as? HTTPURLResponse)?.statusCode, + duration: Date().timeIntervalSince(startedAt), + isSuccess: isSuccess + ) + ) + } + private func currentLanguage() -> String { if let code = Locale.current.language.languageCode?.identifier { return code } if let preferred = Locale.preferredLanguages.first { return String(preferred.prefix(2)) } diff --git a/Projects/Domain/AppUpdateDomain/Tests/Sources/AppUpdateDomainTests.swift b/Projects/Domain/AppUpdateDomain/Tests/Sources/AppUpdateDomainTests.swift new file mode 100644 index 00000000..a3a7f042 --- /dev/null +++ b/Projects/Domain/AppUpdateDomain/Tests/Sources/AppUpdateDomainTests.swift @@ -0,0 +1,14 @@ +// +// AppUpdateDomainTests.swift +// AppUpdateDomainTests +// + +@testable import AppUpdateDomain +import Testing + +struct AppUpdateDomainTests { + @Test + func appUpdateDomainExample() { + #expect(true) + } +} diff --git a/Projects/Domain/Attendance/Project.swift b/Projects/Domain/Attendance/Project.swift deleted file mode 100644 index 80626c2e..00000000 --- a/Projects/Domain/Attendance/Project.swift +++ /dev/null @@ -1,13 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .microModule(name: "AttendanceDomain"), - bundleId: .appBundleID(name: ".AttendanceDomain"), - settings: .settings(), - dependencies: [.SPM.weaveDI, .SPM.composableArchitecture], - interfaceDependencies: [.SPM.weaveDI, .SPM.composableArchitecture] -) diff --git a/Projects/Domain/Attendance/Testing/AttendanceDomainTesting.swift b/Projects/Domain/Attendance/Testing/AttendanceDomainTesting.swift deleted file mode 100644 index 7cc00c3e..00000000 --- a/Projects/Domain/Attendance/Testing/AttendanceDomainTesting.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// AttendanceDomainTesting.swift -// AttendanceDomainTesting -// - -public enum AttendanceDomainTesting {} diff --git a/Projects/Domain/Attendance/Interface/AttendanceCheckResult.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceCheckResult.swift similarity index 100% rename from Projects/Domain/Attendance/Interface/AttendanceCheckResult.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceCheckResult.swift diff --git a/Projects/Domain/Attendance/Interface/AttendanceDay.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceDay.swift similarity index 100% rename from Projects/Domain/Attendance/Interface/AttendanceDay.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceDay.swift diff --git a/Projects/Domain/Attendance/Interface/AttendanceDayStatus.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceDayStatus.swift similarity index 100% rename from Projects/Domain/Attendance/Interface/AttendanceDayStatus.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceDayStatus.swift diff --git a/Projects/Domain/Attendance/Interface/AttendanceError.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceError.swift similarity index 100% rename from Projects/Domain/Attendance/Interface/AttendanceError.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceError.swift diff --git a/Projects/Domain/Attendance/Interface/AttendanceInterface.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceInterface.swift similarity index 52% rename from Projects/Domain/Attendance/Interface/AttendanceInterface.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceInterface.swift index 524f8263..ba5b5b60 100644 --- a/Projects/Domain/Attendance/Interface/AttendanceInterface.swift +++ b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceInterface.swift @@ -1,10 +1,11 @@ // // AttendanceInterface.swift -// DomainInterface +// AttendanceDomainInterface // import Foundation -import WeaveDI + +import ComposableArchitecture public protocol AttendanceInterface: Sendable { /// 오늘의 출석을 체크하고 포인트를 지급받는다. 하루 1회만 가능. @@ -15,16 +16,16 @@ public protocol AttendanceInterface: Sendable { func fetchAttendanceSummary() async throws -> AttendanceSummary } -public struct AttendanceRepositoryDependency: DependencyKey { - public static var liveValue: AttendanceInterface { - UnifiedDI.resolve(AttendanceInterface.self) ?? DefaultAttendanceRepositoryImpl() - } +// Interface 는 `testValue` 만 안다. `liveValue` 는 구현을 소유한 모듈이 등록한다 +// (Repository → Data/Attendance, UseCase → Domain/Attendance/Sources). +// 등록이 빠지면 링크 단계에서 드러나므로 조용한 폴백이 생기지 않는다. - public static var testValue: AttendanceInterface { - UnifiedDI.resolve(AttendanceInterface.self) ?? DefaultAttendanceRepositoryImpl() - } +public enum AttendanceRepositoryDependency: TestDependencyKey { + public static var testValue: AttendanceInterface { MockAttendanceRepository() } +} - public static var previewValue: AttendanceInterface = liveValue +public enum AttendanceUseCaseDependency: TestDependencyKey { + public static var testValue: AttendanceInterface { MockAttendanceRepository() } } public extension DependencyValues { @@ -34,10 +35,9 @@ public extension DependencyValues { } } -// UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var attendanceUseCase: AttendanceInterface { - get { self[AttendanceRepositoryDependency.self] } - set { self[AttendanceRepositoryDependency.self] = newValue } + get { self[AttendanceUseCaseDependency.self] } + set { self[AttendanceUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/Attendance/Interface/AttendanceSummary.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceSummary.swift similarity index 100% rename from Projects/Domain/Attendance/Interface/AttendanceSummary.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/AttendanceSummary.swift diff --git a/Projects/Domain/Attendance/Interface/DefaultAttendanceRepositoryImpl.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/MockAttendanceRepository.swift similarity index 63% rename from Projects/Domain/Attendance/Interface/DefaultAttendanceRepositoryImpl.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/MockAttendanceRepository.swift index 7c2b18a9..d172d0ff 100644 --- a/Projects/Domain/Attendance/Interface/DefaultAttendanceRepositoryImpl.swift +++ b/Projects/Domain/AttendanceDomain/Interface/Sources/MockAttendanceRepository.swift @@ -1,11 +1,12 @@ // -// DefaultAttendanceRepositoryImpl.swift -// DomainInterface +// MockAttendanceRepository.swift +// AttendanceDomainInterface // import Foundation -public struct DefaultAttendanceRepositoryImpl: AttendanceInterface { +/// 계약을 만족하는 테스트/프리뷰용 더블. +public struct MockAttendanceRepository: AttendanceInterface { public init() {} public func checkAttendance() async throws -> AttendanceCheckResult { diff --git a/Projects/Domain/Attendance/Interface/WeeklyAttendance.swift b/Projects/Domain/AttendanceDomain/Interface/Sources/WeeklyAttendance.swift similarity index 100% rename from Projects/Domain/Attendance/Interface/WeeklyAttendance.swift rename to Projects/Domain/AttendanceDomain/Interface/Sources/WeeklyAttendance.swift diff --git a/Projects/Domain/AttendanceDomain/Project.swift b/Projects/Domain/AttendanceDomain/Project.swift new file mode 100644 index 00000000..526574c1 --- /dev/null +++ b/Projects/Domain/AttendanceDomain/Project.swift @@ -0,0 +1,21 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "AttendanceDomain", + bundleId: .appBundleID(name: ".AttendanceDomain"), + product: .framework, + settings: .settings(), + dependencies: [ + .serviceAssembly, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [.SPM.composableArchitecture], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Domain/AttendanceDomain/Sources/AttendanceLiveDependencies.swift b/Projects/Domain/AttendanceDomain/Sources/AttendanceLiveDependencies.swift new file mode 100644 index 00000000..877b7e9d --- /dev/null +++ b/Projects/Domain/AttendanceDomain/Sources/AttendanceLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// AttendanceLiveDependencies.swift +// AttendanceDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import AttendanceDomainInterface +import ComposableArchitecture + +extension AttendanceUseCaseDependency: DependencyKey { + public static var liveValue: AttendanceInterface { AttendanceUseCaseImpl() } +} + +extension AttendanceRepositoryDependency: DependencyKey { + public static var liveValue: AttendanceInterface { AttendanceRepositoryImpl() } +} diff --git a/Projects/Domain/Attendance/Sources/AttendanceUseCase.swift b/Projects/Domain/AttendanceDomain/Sources/AttendanceUseCase.swift similarity index 100% rename from Projects/Domain/Attendance/Sources/AttendanceUseCase.swift rename to Projects/Domain/AttendanceDomain/Sources/AttendanceUseCase.swift diff --git a/Projects/Data/Attendance/Sources/Model/DTO/AttendanceCheckDataDTO.swift b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceCheckDataDTO.swift similarity index 86% rename from Projects/Data/Attendance/Sources/Model/DTO/AttendanceCheckDataDTO.swift rename to Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceCheckDataDTO.swift index 5a38d3f7..9c736f09 100644 --- a/Projects/Data/Attendance/Sources/Model/DTO/AttendanceCheckDataDTO.swift +++ b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceCheckDataDTO.swift @@ -1,11 +1,11 @@ // // AttendanceCheckDataDTO.swift -// Model +// AttendanceDomain // +import PickeNetworkInterface import Foundation -import Model public struct AttendanceCheckDataDTO: Decodable { public let userTag: String? @@ -26,5 +26,3 @@ public struct AttendanceCheckDataDTO: Decodable { case totalPoints = "total_points" } } - -public typealias AttendanceCheckResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Attendance/Sources/Model/DTO/AttendanceDayDTO.swift b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceDayDTO.swift similarity index 90% rename from Projects/Data/Attendance/Sources/Model/DTO/AttendanceDayDTO.swift rename to Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceDayDTO.swift index aaa39e54..355caddb 100644 --- a/Projects/Data/Attendance/Sources/Model/DTO/AttendanceDayDTO.swift +++ b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceDayDTO.swift @@ -1,6 +1,6 @@ // // AttendanceDayDTO.swift -// Model +// AttendanceDomain // import Foundation diff --git a/Projects/Data/Attendance/Sources/Model/DTO/AttendanceSummaryDataDTO.swift b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceSummaryDataDTO.swift similarity index 85% rename from Projects/Data/Attendance/Sources/Model/DTO/AttendanceSummaryDataDTO.swift rename to Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceSummaryDataDTO.swift index 6307f97b..4d9006b1 100644 --- a/Projects/Data/Attendance/Sources/Model/DTO/AttendanceSummaryDataDTO.swift +++ b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/AttendanceSummaryDataDTO.swift @@ -1,11 +1,11 @@ // // AttendanceSummaryDataDTO.swift -// Model +// AttendanceDomain // +import PickeNetworkInterface import Foundation -import Model public struct AttendanceSummaryDataDTO: Decodable { public let userTag: String? @@ -24,5 +24,3 @@ public struct AttendanceSummaryDataDTO: Decodable { case lastAttendedAt = "last_attended_at" } } - -public typealias AttendanceSummaryResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Attendance/Sources/Model/DTO/WeeklyAttendanceDataDTO.swift b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/WeeklyAttendanceDataDTO.swift similarity index 85% rename from Projects/Data/Attendance/Sources/Model/DTO/WeeklyAttendanceDataDTO.swift rename to Projects/Domain/AttendanceDomain/Sources/Model/DTO/WeeklyAttendanceDataDTO.swift index c341650e..4b69adc2 100644 --- a/Projects/Data/Attendance/Sources/Model/DTO/WeeklyAttendanceDataDTO.swift +++ b/Projects/Domain/AttendanceDomain/Sources/Model/DTO/WeeklyAttendanceDataDTO.swift @@ -1,11 +1,11 @@ // // WeeklyAttendanceDataDTO.swift -// Model +// AttendanceDomain // +import PickeNetworkInterface import Foundation -import Model public struct WeeklyAttendanceDataDTO: Decodable { public let userTag: String? @@ -24,5 +24,3 @@ public struct WeeklyAttendanceDataDTO: Decodable { case streakRewardPoints = "streak_reward_points" } } - -public typealias WeeklyAttendanceResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Attendance/Sources/Model/Mapper/AttendanceDataDTO+.swift b/Projects/Domain/AttendanceDomain/Sources/Model/Mapper/AttendanceDataDTO+.swift similarity index 99% rename from Projects/Data/Attendance/Sources/Model/Mapper/AttendanceDataDTO+.swift rename to Projects/Domain/AttendanceDomain/Sources/Model/Mapper/AttendanceDataDTO+.swift index dde3b522..dccbd9a0 100644 --- a/Projects/Data/Attendance/Sources/Model/Mapper/AttendanceDataDTO+.swift +++ b/Projects/Domain/AttendanceDomain/Sources/Model/Mapper/AttendanceDataDTO+.swift @@ -1,6 +1,6 @@ // // AttendanceDataDTO+.swift -// Model +// AttendanceDomain // import Foundation diff --git a/Projects/Domain/AttendanceDomain/Sources/Repository/AttendanceRepositoryImpl.swift b/Projects/Domain/AttendanceDomain/Sources/Repository/AttendanceRepositoryImpl.swift new file mode 100644 index 00000000..725ba6be --- /dev/null +++ b/Projects/Domain/AttendanceDomain/Sources/Repository/AttendanceRepositoryImpl.swift @@ -0,0 +1,46 @@ +// +// AttendanceRepositoryImpl.swift +// Repository +// + +import Foundation + +import Dependencies + +import APIEndpoint +import AttendanceDomainInterface +import PickeNetwork + + +public final class AttendanceRepositoryImpl: AttendanceInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func checkAttendance() async throws -> AttendanceCheckResult { + let data = try await client.send( + AttendanceService.check, + as: AttendanceCheckDataDTO.self + ) + + return data.toDomain() + } + + public func fetchWeeklyAttendance() async throws -> WeeklyAttendance { + let data = try await client.send( + AttendanceService.weekly, + as: WeeklyAttendanceDataDTO.self + ) + + return data.toDomain() + } + + public func fetchAttendanceSummary() async throws -> AttendanceSummary { + let data = try await client.send( + AttendanceService.summary, + as: AttendanceSummaryDataDTO.self + ) + + return data.toDomain() + } +} diff --git a/Projects/Domain/Attendance/Tests/AttendanceDomainTests.swift b/Projects/Domain/AttendanceDomain/Tests/Sources/AttendanceDomainTests.swift similarity index 100% rename from Projects/Domain/Attendance/Tests/AttendanceDomainTests.swift rename to Projects/Domain/AttendanceDomain/Tests/Sources/AttendanceDomainTests.swift diff --git a/Projects/Data/Attendance/Tests/AttendanceRepositoryTests.swift b/Projects/Domain/AttendanceDomain/Tests/Sources/AttendanceRepositoryTests.swift similarity index 73% rename from Projects/Data/Attendance/Tests/AttendanceRepositoryTests.swift rename to Projects/Domain/AttendanceDomain/Tests/Sources/AttendanceRepositoryTests.swift index cf69a32f..767a3201 100644 --- a/Projects/Data/Attendance/Tests/AttendanceRepositoryTests.swift +++ b/Projects/Domain/AttendanceDomain/Tests/Sources/AttendanceRepositoryTests.swift @@ -4,11 +4,14 @@ // import Foundation + +import Dependencies import Testing +import APIEndpoint import AttendanceDomainInterface -@testable import AttendanceData +@testable import AttendanceDomain @Suite("출석체크 리포지토리") struct AttendanceRepositoryTests { @@ -29,9 +32,11 @@ struct AttendanceRepositoryTests { "error": null } """ - let sut = AttendanceRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let sut = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + AttendanceRepositoryImpl() + } let result = try await sut.checkAttendance() @@ -62,9 +67,11 @@ struct AttendanceRepositoryTests { "error": null } """ - let sut = AttendanceRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let sut = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + AttendanceRepositoryImpl() + } let weekly = try await sut.fetchWeeklyAttendance() @@ -78,16 +85,22 @@ struct AttendanceRepositoryTests { #expect(weekly.isStreakAlive == false) } - @Test("data 가 비어 있으면 backendError 를 던진다") - func throwsOnEmptyPayload() async { + @Test("서버 error 봉투는 네트워크 response 에러로 전파한다") + func throwsNetworkResponseErrorOnEnvelopeFailure() async { let json = """ { "statusCode": 500, "data": null, "error": { "code": "E500", "message": "서버 오류" } } """ - let sut = AttendanceRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let sut = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + AttendanceRepositoryImpl() + } - await #expect(throws: AttendanceError.backendError("서버 오류")) { + await expectNetworkResponseError( + statusCode: 500, + code: "E500", + message: "서버 오류" + ) { try await sut.checkAttendance() } } diff --git a/Projects/Domain/AttendanceDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/AttendanceDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..d155e2da --- /dev/null +++ b/Projects/Domain/AttendanceDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,127 @@ +// +// TestSupport.swift +// AttendanceDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Auth/Project.swift b/Projects/Domain/Auth/Project.swift deleted file mode 100644 index 6b437ede..00000000 --- a/Projects/Domain/Auth/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .microModule(name: "AuthDomain"), - bundleId: .appBundleID(name: ".AuthDomain"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(implements: .Entity), - .Domain(implements: .DomainInterface), - .SPM.composableArchitecture, - .SPM.weaveDI, - .SPM.logMarco, - ], - interfaceDependencies: [ - .Domain(implements: .Entity), - .SPM.weaveDI, - .SPM.composableArchitecture, - ] -) diff --git a/Projects/Domain/Auth/Sources/OAuth/Dependencies+OAuth.swift b/Projects/Domain/Auth/Sources/OAuth/Dependencies+OAuth.swift deleted file mode 100644 index eb886615..00000000 --- a/Projects/Domain/Auth/Sources/OAuth/Dependencies+OAuth.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Dependencies+OAuth.swift -// UseCase -// -// Created by Wonji Suh on 12/29/25. -// - -import Dependencies -import AuthDomainInterface -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/Auth/Interface/Contract/Apple/Interface/AppleAuthRequestInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleAuthRequestInterface.swift similarity index 56% rename from Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleAuthRequestInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleAuthRequestInterface.swift index 9c98d6f2..e0789086 100644 --- a/Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleAuthRequestInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleAuthRequestInterface.swift @@ -7,23 +7,15 @@ import Foundation import AuthenticationServices -import WeaveDI +import ComposableArchitecture 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 +public enum AppleAuthRequestDependency: TestDependencyKey { + public static var testValue: AppleAuthRequestInterface { MockAppleAuthRequest() } } /// DependencyValues extension으로 간편한 접근 제공 diff --git a/Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleOAuthInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleOAuthInterface.swift similarity index 59% rename from Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleOAuthInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleOAuthInterface.swift index 203252f7..a9a2386e 100644 --- a/Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleOAuthInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleOAuthInterface.swift @@ -9,7 +9,7 @@ import Foundation import AuthenticationServices -import WeaveDI +import ComposableArchitecture public protocol AppleOAuthInterface: Sendable { func signIn() async throws -> AppleOAuthPayload @@ -20,14 +20,8 @@ public protocol AppleOAuthInterface: Sendable { } // 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 enum AppleOAuthRepositoryDependencyKey: TestDependencyKey { + public static var testValue: AppleOAuthInterface { MockAppleOAuthRepository() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleOAuthProviderInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleOAuthProviderInterface.swift similarity index 76% rename from Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleOAuthProviderInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleOAuthProviderInterface.swift index eff78271..f6ca94c7 100644 --- a/Projects/Domain/Auth/Interface/Contract/Apple/Interface/AppleOAuthProviderInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/AppleOAuthProviderInterface.swift @@ -7,7 +7,7 @@ import Foundation import AuthenticationServices -import WeaveDI +import ComposableArchitecture /// Apple OAuth Provider Interface 프로토콜 public protocol AppleOAuthProviderInterface: Sendable { @@ -20,16 +20,8 @@ public protocol AppleOAuthProviderInterface: Sendable { } /// 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 +public enum AppleOAuthProviderDependency: TestDependencyKey { + public static var testValue: AppleOAuthProviderInterface { MockAppleOAuthProvider() } } /// DependencyValues extension으로 간편한 접근 제공 diff --git a/Projects/Domain/Auth/Interface/Contract/Apple/Default/DefaultAppleAuthRequestImpl.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/Default/MockAppleAuthRequest.swift similarity index 93% rename from Projects/Domain/Auth/Interface/Contract/Apple/Default/DefaultAppleAuthRequestImpl.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/Default/MockAppleAuthRequest.swift index 911dc97d..a0d821e0 100644 --- a/Projects/Domain/Auth/Interface/Contract/Apple/Default/DefaultAppleAuthRequestImpl.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/Default/MockAppleAuthRequest.swift @@ -1,5 +1,5 @@ // -// DefaultAppleAuthRequestImpl.swift +// MockAppleAuthRequest.swift // DomainInterface // // Created by Wonji Suh on 12/26/25. @@ -12,7 +12,7 @@ import CryptoKit /// Default fallback implementation of AppleAuthRequestInterface -public struct DefaultAppleAuthRequestImpl: AppleAuthRequestInterface { +public struct MockAppleAuthRequest: AppleAuthRequestInterface { public init() {} diff --git a/Projects/Domain/Auth/Interface/Contract/Apple/Default/MockAppleOAuthRepository.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/Default/MockAppleOAuthRepository.swift similarity index 98% rename from Projects/Domain/Auth/Interface/Contract/Apple/Default/MockAppleOAuthRepository.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/Default/MockAppleOAuthRepository.swift index a0046611..0bfc98f0 100644 --- a/Projects/Domain/Auth/Interface/Contract/Apple/Default/MockAppleOAuthRepository.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Apple/Default/MockAppleOAuthRepository.swift @@ -101,11 +101,9 @@ public actor MockAppleOAuthRepository: AppleOAuthInterface { _ 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)) } @@ -125,11 +123,9 @@ public actor MockAppleOAuthRepository: AppleOAuthInterface { } 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)) } diff --git a/Projects/Domain/Auth/Interface/Contract/Auth/Interface/AuthInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/AuthInterface.swift similarity index 62% rename from Projects/Domain/Auth/Interface/Contract/Auth/Interface/AuthInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/AuthInterface.swift index c303b873..a9bd260f 100644 --- a/Projects/Domain/Auth/Interface/Contract/Auth/Interface/AuthInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/AuthInterface.swift @@ -6,7 +6,7 @@ // import Foundation -import WeaveDI +import ComposableArchitecture /// Auth 관련 비즈니스 로직을 위한 Interface 프로토콜 public protocol AuthInterface: Sendable { @@ -19,20 +19,12 @@ public protocol AuthInterface: Sendable { func refresh() async throws -> AuthTokens func withDraw(reason: String) async throws -> WithdrawEntity func logout() async throws -> AuthExitEntity - func updateSessionCredential(with tokens: AuthTokens) + func updateSessionCredential(with tokens: AuthTokens) async } /// Auth Repository 의 DependencyKey 구조체 -public struct AuthRepositoryDependency: DependencyKey { - public static var liveValue: AuthInterface { - return UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() - } - - public static var testValue: AuthInterface { - return UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() - } - - public static var previewValue: AuthInterface = liveValue +public enum AuthRepositoryDependency: TestDependencyKey { + public static var testValue: AuthInterface { MockAuthRepository() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Contract/Auth/Interface/AuthUseCaseInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/AuthUseCaseInterface.swift similarity index 51% rename from Projects/Domain/Auth/Interface/Contract/Auth/Interface/AuthUseCaseInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/AuthUseCaseInterface.swift index e9545fa8..a1ab9daf 100644 --- a/Projects/Domain/Auth/Interface/Contract/Auth/Interface/AuthUseCaseInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/AuthUseCaseInterface.swift @@ -4,22 +4,14 @@ // import Foundation -import WeaveDI +import ComposableArchitecture /// Auth 관련 비즈니스 로직 UseCase 를 위한 Interface 프로토콜 public protocol AuthUseCaseInterface: AuthInterface {} /// Auth UseCase 의 DependencyKey 구조체 -public struct AuthUseCaseDependency: DependencyKey { - public static var liveValue: AuthUseCaseInterface { - return UnifiedDI.resolve(AuthUseCaseInterface.self) ?? DefaultAuthUseCaseImpl() - } - - public static var testValue: AuthUseCaseInterface { - return UnifiedDI.resolve(AuthUseCaseInterface.self) ?? DefaultAuthUseCaseImpl() - } - - public static var previewValue: AuthUseCaseInterface = liveValue +public enum AuthUseCaseDependency: TestDependencyKey { + public static var testValue: AuthUseCaseInterface { MockAuthUseCase() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Contract/Auth/Default/DefaultAuthRepositoryImpl.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/Default/MockAuthRepository.swift similarity index 87% rename from Projects/Domain/Auth/Interface/Contract/Auth/Default/DefaultAuthRepositoryImpl.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/Default/MockAuthRepository.swift index b6181dd1..93415b06 100644 --- a/Projects/Domain/Auth/Interface/Contract/Auth/Default/DefaultAuthRepositoryImpl.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/Default/MockAuthRepository.swift @@ -1,5 +1,5 @@ // -// DefaultAuthRepositoryImpl.swift +// MockAuthRepository.swift // DomainInterface // // Created by Wonji Suh on 5/14/26. @@ -8,7 +8,7 @@ import Foundation /// Auth Repository 의 기본 구현체 (테스트 / 프리뷰용 no-op) -public final class DefaultAuthRepositoryImpl: AuthInterface, @unchecked Sendable { +public final class MockAuthRepository: AuthInterface, @unchecked Sendable { public init() {} public func login( @@ -50,7 +50,7 @@ public final class DefaultAuthRepositoryImpl: AuthInterface, @unchecked Sendable ) } - public func updateSessionCredential(with _: AuthTokens) { + public func updateSessionCredential(with _: AuthTokens) async { // no-op } } diff --git a/Projects/Domain/Auth/Interface/Contract/Auth/Default/DefaultAuthUseCaseImpl.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/Default/MockAuthUseCase.swift similarity index 87% rename from Projects/Domain/Auth/Interface/Contract/Auth/Default/DefaultAuthUseCaseImpl.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/Default/MockAuthUseCase.swift index e36bd0f3..6d9b0ff2 100644 --- a/Projects/Domain/Auth/Interface/Contract/Auth/Default/DefaultAuthUseCaseImpl.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Auth/Default/MockAuthUseCase.swift @@ -1,12 +1,12 @@ // -// DefaultAuthUseCaseImpl.swift +// MockAuthUseCase.swift // DomainInterface // import Foundation /// Auth UseCase 의 기본 구현체 (테스트 / 프리뷰용 no-op) -public final class DefaultAuthUseCaseImpl: AuthUseCaseInterface, @unchecked Sendable { +public final class MockAuthUseCase: AuthUseCaseInterface, @unchecked Sendable { public init() {} public func login( @@ -48,7 +48,7 @@ public final class DefaultAuthUseCaseImpl: AuthUseCaseInterface, @unchecked Send ) } - public func updateSessionCredential(with _: AuthTokens) { + public func updateSessionCredential(with _: AuthTokens) async { // no-op } } diff --git a/Projects/Domain/Auth/Interface/Contract/Google/Default/MockGoogleOAuthRepository.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/Default/MockGoogleOAuthRepository.swift similarity index 99% rename from Projects/Domain/Auth/Interface/Contract/Google/Default/MockGoogleOAuthRepository.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/Default/MockGoogleOAuthRepository.swift index f9dd6ac3..7e4fdc49 100644 --- a/Projects/Domain/Auth/Interface/Contract/Google/Default/MockGoogleOAuthRepository.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/Default/MockGoogleOAuthRepository.swift @@ -82,11 +82,9 @@ public actor MockGoogleOAuthRepository: GoogleOAuthInterface { // 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)) } diff --git a/Projects/Domain/Auth/Interface/Contract/Google/Interface/GoogleOAuthInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/GoogleOAuthInterface.swift similarity index 50% rename from Projects/Domain/Auth/Interface/Contract/Google/Interface/GoogleOAuthInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/GoogleOAuthInterface.swift index 702551e5..f9eaf446 100644 --- a/Projects/Domain/Auth/Interface/Contract/Google/Interface/GoogleOAuthInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/GoogleOAuthInterface.swift @@ -6,20 +6,14 @@ // import Foundation -import WeaveDI +import ComposableArchitecture 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 enum GoogleOAuthRepositoryDependencyKey: TestDependencyKey { + public static var testValue: GoogleOAuthInterface { MockGoogleOAuthRepository() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Contract/Google/Interface/GoogleOAuthProviderInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/GoogleOAuthProviderInterface.swift similarity index 69% rename from Projects/Domain/Auth/Interface/Contract/Google/Interface/GoogleOAuthProviderInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/GoogleOAuthProviderInterface.swift index 575cf99a..758969e8 100644 --- a/Projects/Domain/Auth/Interface/Contract/Google/Interface/GoogleOAuthProviderInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Google/GoogleOAuthProviderInterface.swift @@ -6,7 +6,7 @@ // import Foundation -import WeaveDI +import ComposableArchitecture /// Google OAuth Provider Interface 프로토콜 public protocol GoogleOAuthProviderInterface: Sendable { @@ -14,16 +14,8 @@ public protocol GoogleOAuthProviderInterface: Sendable { } /// Google OAuth Provider 의 DependencyKey 구조체 -public struct GoogleOAuthProviderDependency: DependencyKey { - public static var liveValue: GoogleOAuthProviderInterface { - return UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() - } - - public static var testValue: GoogleOAuthProviderInterface { - return UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() - } - - public static var previewValue: GoogleOAuthProviderInterface = testValue +public enum GoogleOAuthProviderDependency: TestDependencyKey { + public static var testValue: GoogleOAuthProviderInterface { MockGoogleOAuthProvider() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Contract/Kakao/Default/MockKakaoOAuthRepository.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/Default/MockKakaoOAuthRepository.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Contract/Kakao/Default/MockKakaoOAuthRepository.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/Default/MockKakaoOAuthRepository.swift diff --git a/Projects/Domain/Auth/Interface/Contract/Kakao/Interface/KakaoOAuthProviderInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/KakaoOAuthProviderInterface.swift similarity index 71% rename from Projects/Domain/Auth/Interface/Contract/Kakao/Interface/KakaoOAuthProviderInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/KakaoOAuthProviderInterface.swift index 67efaebe..7a79d923 100644 --- a/Projects/Domain/Auth/Interface/Contract/Kakao/Interface/KakaoOAuthProviderInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/KakaoOAuthProviderInterface.swift @@ -6,7 +6,7 @@ // import Foundation -import WeaveDI +import ComposableArchitecture /// Kakao OAuth Provider Interface 프로토콜 public protocol KakaoOAuthProviderInterface: Sendable { @@ -14,16 +14,8 @@ public protocol KakaoOAuthProviderInterface: Sendable { } /// Kakao OAuth Provider의 DependencyKey 구조체 -public struct KakaoOAuthProviderDependency: DependencyKey { - public static var liveValue: KakaoOAuthProviderInterface { - return UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() - } - - public static var testValue: KakaoOAuthProviderInterface { - return UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() - } - - public static var previewValue: KakaoOAuthProviderInterface = testValue +public enum KakaoOAuthProviderDependency: TestDependencyKey { + public static var testValue: KakaoOAuthProviderInterface { MockKakaoOAuthProvider() } } /// DependencyValues extension으로 간편한 접근 제공 diff --git a/Projects/Domain/Auth/Interface/Contract/Kakao/Interface/KakaoOAuthRepositoryProtocol.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/KakaoOAuthRepositoryProtocol.swift similarity index 55% rename from Projects/Domain/Auth/Interface/Contract/Kakao/Interface/KakaoOAuthRepositoryProtocol.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/KakaoOAuthRepositoryProtocol.swift index 052fbcee..0aa386e9 100644 --- a/Projects/Domain/Auth/Interface/Contract/Kakao/Interface/KakaoOAuthRepositoryProtocol.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/Kakao/KakaoOAuthRepositoryProtocol.swift @@ -7,7 +7,7 @@ import Dependencies import Foundation -import WeaveDI +import ComposableArchitecture public protocol KakaoOAuthInterface: Sendable { func signIn() async throws -> KakaoOAuthPayload @@ -15,13 +15,8 @@ public protocol KakaoOAuthInterface: Sendable { // MARK: - Dependencies -public struct KakaoOAuthRepositoryDependencyKey: DependencyKey { - public static var liveValue: KakaoOAuthInterface { - return UnifiedDI.resolve(KakaoOAuthInterface.self) ?? MockKakaoOAuthRepository() - } - - public static var previewValue: KakaoOAuthInterface = MockKakaoOAuthRepository() - public static var testValue: KakaoOAuthInterface = MockKakaoOAuthRepository() +public enum KakaoOAuthRepositoryDependencyKey: TestDependencyKey { + public static var testValue: KakaoOAuthInterface { MockKakaoOAuthRepository() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Contract/OAuth/Default/DefaultUnifiedOAuthUseCaseImpl.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/OAuth/Default/MockUnifiedOAuthUseCase.swift similarity index 66% rename from Projects/Domain/Auth/Interface/Contract/OAuth/Default/DefaultUnifiedOAuthUseCaseImpl.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/OAuth/Default/MockUnifiedOAuthUseCase.swift index c607faf8..76394a24 100644 --- a/Projects/Domain/Auth/Interface/Contract/OAuth/Default/DefaultUnifiedOAuthUseCaseImpl.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/OAuth/Default/MockUnifiedOAuthUseCase.swift @@ -1,14 +1,14 @@ // -// DefaultUnifiedOAuthUseCaseImpl.swift +// MockUnifiedOAuthUseCase.swift // DomainInterface // @preconcurrency import AuthenticationServices -import Entity import Foundation +import AuthDomainInterface /// 통합 OAuth UseCase 의 기본 구현체 (테스트 / 프리뷰용 no-op) -public final class DefaultUnifiedOAuthUseCaseImpl: UnifiedOAuthUseCaseInterface, @unchecked Sendable { +public final class MockUnifiedOAuthUseCase: UnifiedOAuthUseCaseInterface, @unchecked Sendable { public init() {} public func processOAuthFlow( @@ -18,6 +18,6 @@ public final class DefaultUnifiedOAuthUseCaseImpl: UnifiedOAuthUseCaseInterface, googleToken _: String?, kakaoToken _: String? ) async -> Result { - .failure(.unknownError("DefaultUnifiedOAuthUseCaseImpl")) + .failure(.unknownError("MockUnifiedOAuthUseCase")) } } diff --git a/Projects/Domain/Auth/Interface/Contract/OAuth/Interface/UnifiedOAuthUseCaseInterface.swift b/Projects/Domain/AuthDomain/Interface/Sources/Contract/OAuth/UnifiedOAuthUseCaseInterface.swift similarity index 61% rename from Projects/Domain/Auth/Interface/Contract/OAuth/Interface/UnifiedOAuthUseCaseInterface.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Contract/OAuth/UnifiedOAuthUseCaseInterface.swift index bc409f3d..11647976 100644 --- a/Projects/Domain/Auth/Interface/Contract/OAuth/Interface/UnifiedOAuthUseCaseInterface.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Contract/OAuth/UnifiedOAuthUseCaseInterface.swift @@ -4,9 +4,9 @@ // @preconcurrency import AuthenticationServices -import Entity import Foundation -import WeaveDI +import ComposableArchitecture +import AuthDomainInterface /// 통합 OAuth UseCase 를 위한 Interface 프로토콜 public protocol UnifiedOAuthUseCaseInterface: Sendable { @@ -21,16 +21,8 @@ public protocol UnifiedOAuthUseCaseInterface: Sendable { } /// 통합 OAuth UseCase 의 DependencyKey 구조체 -public struct UnifiedOAuthUseCaseDependency: DependencyKey { - public static var liveValue: UnifiedOAuthUseCaseInterface { - return UnifiedDI.resolve(UnifiedOAuthUseCaseInterface.self) ?? DefaultUnifiedOAuthUseCaseImpl() - } - - public static var testValue: UnifiedOAuthUseCaseInterface { - return UnifiedDI.resolve(UnifiedOAuthUseCaseInterface.self) ?? DefaultUnifiedOAuthUseCaseImpl() - } - - public static var previewValue: UnifiedOAuthUseCaseInterface = liveValue +public enum UnifiedOAuthUseCaseDependency: TestDependencyKey { + public static var testValue: UnifiedOAuthUseCaseInterface { MockUnifiedOAuthUseCase() } } public extension DependencyValues { diff --git a/Projects/Domain/Auth/Interface/Entity/Auth/AuthExitEntity.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/AuthExitEntity.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/Auth/AuthExitEntity.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/AuthExitEntity.swift diff --git a/Projects/Domain/Auth/Interface/Entity/Auth/SocialType.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/SocialType.swift similarity index 90% rename from Projects/Domain/Auth/Interface/Entity/Auth/SocialType.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/SocialType.swift index dd3bc4d9..4dc0b448 100644 --- a/Projects/Domain/Auth/Interface/Entity/Auth/SocialType.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/SocialType.swift @@ -35,7 +35,6 @@ public enum SocialType: String, CaseIterable, Identifiable, Hashable { } /// 백엔드 OAuth code 교환에 사용되는 redirect URI. - /// 카카오/구글 authorize URL 에 그대로 사용한 값과 동일해야 한다. public var redirectUri: String { switch self { case .kakao: diff --git a/Projects/Domain/Auth/Interface/Entity/Auth/TermsDocument.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/TermsDocument.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/Auth/TermsDocument.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/TermsDocument.swift diff --git a/Projects/Domain/Auth/Interface/Entity/Auth/WithdrawEntity.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/WithdrawEntity.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/Auth/WithdrawEntity.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/Auth/WithdrawEntity.swift diff --git a/Projects/Domain/Entity/Sources/Error/AuthError.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/AuthError.swift similarity index 93% rename from Projects/Domain/Entity/Sources/Error/AuthError.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/AuthError.swift index 1c94b929..a9b6dc96 100644 --- a/Projects/Domain/Entity/Sources/Error/AuthError.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/AuthError.swift @@ -1,6 +1,6 @@ // // AuthError.swift -// Entity +// AuthDomainInterface // // Created by Wonji Suh on 12/29/25. // @@ -47,13 +47,13 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable { return "ID 토큰을 가져오지 못했습니다." case .userCancelled: return "사용자가 로그인을 취소했습니다." - case .invalidCredential(let message): + case let .invalidCredential(message): return "잘못된 자격 증명입니다: \(message)" - case .networkError(let message): + case let .networkError(message): return "네트워크 오류가 발생했습니다: \(message)" - case .backendError(let message): + case let .backendError(message): return "서버에서 오류가 발생했습니다: \(message)" - case .needsTermsAgreement(let message): + case let .needsTermsAgreement(message): return "\(message)" case .accountDeletionFailed: return "회원 탈퇴에 실패했습니다." @@ -63,7 +63,7 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable { return "이미 탈퇴된 계정입니다." case .refreshTokenExpired: return "로그인이 만료되었습니다. 다시 로그인해주세요." - case .unknownError(let message): + case let .unknownError(message): return "알 수 없는 오류가 발생했습니다: \(message)" } } diff --git a/Projects/Domain/Auth/Interface/Entity/OAuth/AppleOAuthPayload.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AppleOAuthPayload.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/OAuth/AppleOAuthPayload.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AppleOAuthPayload.swift diff --git a/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AppleUserNameSharedKey.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AppleUserNameSharedKey.swift new file mode 100644 index 00000000..4fbca070 --- /dev/null +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AppleUserNameSharedKey.swift @@ -0,0 +1,43 @@ +// +// AppleUserNameSharedKey.swift +// AuthDomainInterface +// + +import Foundation + +import PickeStorageInterface + +import Sharing + +public extension SharedReaderKey where Self == PersistentSharedKey.Default { + /// Apple 로그인이 최초 1회만 내려주는 표시 이름. + static var appleUserName: Self { + Self[ + .persistent( + "AppleUserName", + encode: { name in + try JSONEncoder().encode(AppleUserNameSnapshot(name)) + }, + decode: { data in + try JSONDecoder().decode(AppleUserNameSnapshot.self, from: data).name + }, + legacyData: { + // `.appStorage("appleUserName")` 로 쌓아둔 값을 첫 조회 때 옮긴다. + guard let name = UserDefaults.standard.string(forKey: "appleUserName") else { return nil } + return try JSONEncoder().encode(AppleUserNameSnapshot(name)) + } + ), + default: nil + ] + } +} + +private struct AppleUserNameSnapshot: Codable, Sendable { + let schemaVersion: Int + let name: String? + + init(_ name: String?) { + schemaVersion = 1 + self.name = name + } +} diff --git a/Projects/Domain/Auth/Interface/Entity/OAuth/AuthToken.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AuthToken.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/OAuth/AuthToken.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/AuthToken.swift diff --git a/Projects/Domain/Auth/Interface/Entity/OAuth/GoogleOAuthPayload.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/GoogleOAuthPayload.swift similarity index 90% rename from Projects/Domain/Auth/Interface/Entity/OAuth/GoogleOAuthPayload.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/GoogleOAuthPayload.swift index d79e4df0..b2b34580 100644 --- a/Projects/Domain/Auth/Interface/Entity/OAuth/GoogleOAuthPayload.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/GoogleOAuthPayload.swift @@ -8,7 +8,6 @@ import Foundation /// Google OAuth 콜백에서 받은 백엔드 로그인 결과 -/// 백엔드가 redirect_uri 를 직접 처리하고 picke:// 딥링크에 토큰을 실어 보내는 흐름. public struct GoogleOAuthPayload { public let idToken: String public let accessToken: String? diff --git a/Projects/Domain/Auth/Interface/Entity/OAuth/KakaoOAuthPayload.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/KakaoOAuthPayload.swift similarity index 91% rename from Projects/Domain/Auth/Interface/Entity/OAuth/KakaoOAuthPayload.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/KakaoOAuthPayload.swift index 2c73cbb4..b10dfabf 100644 --- a/Projects/Domain/Auth/Interface/Entity/OAuth/KakaoOAuthPayload.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/KakaoOAuthPayload.swift @@ -8,7 +8,6 @@ import Foundation /// Kakao OAuth 콜백에서 받은 백엔드 로그인 결과 -/// 백엔드가 redirect_uri 를 직접 처리하고 picke:// 딥링크에 토큰을 실어 보내는 흐름. public struct KakaoOAuthPayload { public let idToken: String public let accessToken: String diff --git a/Projects/Domain/Auth/Interface/Entity/OAuth/LoginEntity.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/LoginEntity.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/OAuth/LoginEntity.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/LoginEntity.swift diff --git a/Projects/Domain/Auth/Interface/Entity/OAuth/UserSession.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/UserSession.swift similarity index 100% rename from Projects/Domain/Auth/Interface/Entity/OAuth/UserSession.swift rename to Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/UserSession.swift diff --git a/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/UserSessionSharedKey.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/UserSessionSharedKey.swift new file mode 100644 index 00000000..b1557530 --- /dev/null +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/OAuth/UserSessionSharedKey.swift @@ -0,0 +1,47 @@ +// +// UserSessionSharedKey.swift +// AuthDomainInterface +// + +import Foundation + +import PickeStorageInterface + +import Sharing + +public extension SharedReaderKey where Self == PersistentSharedKey.Default { + /// 앱 실행 사이에 유지되는 사용자 세션 메타데이터. + static var userSession: Self { + Self[ + .persistent( + "UserSession", + encode: { session in + try JSONEncoder().encode(UserSessionSnapshot(session)) + }, + decode: { data in + try JSONDecoder().decode(UserSessionSnapshot.self, from: data).userSession + } + ), + default: .empty + ] + } +} + +private struct UserSessionSnapshot: Codable, Sendable { + let schemaVersion: Int + let name: String + let provider: String + + init(_ session: UserSession) { + schemaVersion = 1 + name = session.name + provider = session.provider.rawValue + } + + var userSession: UserSession { + UserSession( + name: name, + provider: SocialType(rawValue: provider) ?? .apple + ) + } +} diff --git a/Projects/Domain/AuthDomain/Project.swift b/Projects/Domain/AuthDomain/Project.swift new file mode 100644 index 00000000..6c6b96bd --- /dev/null +++ b/Projects/Domain/AuthDomain/Project.swift @@ -0,0 +1,29 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "AuthDomain", + bundleId: .appBundleID(name: ".AuthDomain"), + product: .framework, + settings: .settings(), + dependencies: [ + .serviceAssembly, + .core(.storage, .interface), + .service(.auth, .interface), + .SPM.googleSignIn, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + // UserSessionSharedKey 가 PersistentSharedKey(PickeStorageInterface)와 Sharing 을 직접 쓴다. + .core(.storage, .interface), + .SPM.sharing, + .SPM.composableArchitecture, + ], + hasTesting: true +) \ No newline at end of file diff --git a/Projects/Domain/Auth/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/AuthDomain/Sources/Auth/AuthUseCaseImpl.swift similarity index 65% rename from Projects/Domain/Auth/Sources/Auth/AuthUseCaseImpl.swift rename to Projects/Domain/AuthDomain/Sources/Auth/AuthUseCaseImpl.swift index 60a2a5c3..b6c9ef96 100644 --- a/Projects/Domain/Auth/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/AuthDomain/Sources/Auth/AuthUseCaseImpl.swift @@ -8,16 +8,16 @@ import Foundation import AuthDomainInterface -import DomainInterface -import Entity import ComposableArchitecture -import WeaveDI +import PickeStorageInterface +import PickeAuthInterface public struct AuthUseCaseImpl: AuthUseCaseInterface { @Dependency(\.authRepository) var authRepository - @Dependency(\.keychainManager) private var keychainManager: KeychainManaging - @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Dependency(\.authService) private var authService: any AuthService + @Dependency(\.sessionCacheInvalidator) private var sessionCacheInvalidator + @Shared(.userSession) var userSession: UserSession public init() {} @@ -42,37 +42,34 @@ public struct AuthUseCaseImpl: AuthUseCaseInterface { $0.provider = result.provider $0.name = result.name } - keychainManager.save( - accessToken: result.token.accessToken, - refreshToken: result.token.refreshToken - ) - authRepository.updateSessionCredential(with: result.token) + await 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) + await authRepository.updateSessionCredential(with: tokens) return tokens } public func logout() async throws -> AuthExitEntity { let result = try await authRepository.logout() - keychainManager.clear() + await authService.signOut() + await sessionCacheInvalidator.invalidate() return result } public func withDraw(reason: String) async throws -> WithdrawEntity { let result = try await authRepository.withDraw(reason: reason) if result.withdrawn { - keychainManager.clear() + await authService.signOut() + await sessionCacheInvalidator.invalidate() } return result } - public func updateSessionCredential(with tokens: AuthTokens) { - authRepository.updateSessionCredential(with: tokens) + public func updateSessionCredential(with tokens: AuthTokens) async { + await authRepository.updateSessionCredential(with: tokens) } } diff --git a/Projects/Domain/AuthDomain/Sources/AuthLiveDependencies.swift b/Projects/Domain/AuthDomain/Sources/AuthLiveDependencies.swift new file mode 100644 index 00000000..92256984 --- /dev/null +++ b/Projects/Domain/AuthDomain/Sources/AuthLiveDependencies.swift @@ -0,0 +1,53 @@ +// +// AuthLiveDependencies.swift +// AuthDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import AuthDomainInterface +import ComposableArchitecture + +extension AuthUseCaseDependency: DependencyKey { + public static var liveValue: AuthUseCaseInterface { AuthUseCaseImpl() } +} + +extension UnifiedOAuthUseCaseDependency: DependencyKey { + public static var liveValue: UnifiedOAuthUseCaseInterface { UnifiedOAuthUseCase() } +} + +extension AppleOAuthProviderDependency: DependencyKey { + public static var liveValue: AppleOAuthProviderInterface { AppleOAuthProvider() } +} + +extension GoogleOAuthProviderDependency: DependencyKey { + public static var liveValue: GoogleOAuthProviderInterface { GoogleOAuthProvider() } +} + +extension KakaoOAuthProviderDependency: DependencyKey { + public static var liveValue: KakaoOAuthProviderInterface { KakaoOAuthProvider() } +} + +extension AuthRepositoryDependency: DependencyKey { + public static var liveValue: AuthInterface { AuthRepositoryImpl() } +} + +extension AppleAuthRequestDependency: DependencyKey { + public static var liveValue: AppleAuthRequestInterface { AppleLoginRepositoryImpl() } +} + +extension AppleOAuthRepositoryDependencyKey: DependencyKey { + public static var liveValue: AppleOAuthInterface { AppleOAuthRepositoryImpl() } +} + +extension GoogleOAuthRepositoryDependencyKey: DependencyKey { + public static var liveValue: GoogleOAuthInterface { + GoogleOAuthRepositoryImpl(presentationContextProvider: AuthPresentationContextProvider()) + } +} + +extension KakaoOAuthRepositoryDependencyKey: DependencyKey { + public static var liveValue: KakaoOAuthInterface { + KakaoOAuthRepository(presentationContextProvider: AuthPresentationContextProvider()) + } +} diff --git a/Projects/Domain/Auth/Sources/Exported/AuthDomainExported.swift b/Projects/Domain/AuthDomain/Sources/Exported/AuthDomainExported.swift similarity index 100% rename from Projects/Domain/Auth/Sources/Exported/AuthDomainExported.swift rename to Projects/Domain/AuthDomain/Sources/Exported/AuthDomainExported.swift diff --git a/Projects/Data/Auth/Sources/Model/Login/DTO/LoginDataDTO.swift b/Projects/Domain/AuthDomain/Sources/Model/Login/DTO/LoginDataDTO.swift similarity index 85% rename from Projects/Data/Auth/Sources/Model/Login/DTO/LoginDataDTO.swift rename to Projects/Domain/AuthDomain/Sources/Model/Login/DTO/LoginDataDTO.swift index 6f64b41a..f0522efc 100644 --- a/Projects/Data/Auth/Sources/Model/Login/DTO/LoginDataDTO.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Login/DTO/LoginDataDTO.swift @@ -1,12 +1,12 @@ // // LoginDataDTO.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // +import PickeNetworkInterface import Foundation -import Model public struct LoginDataDTO: Decodable { public let accessToken: String @@ -25,4 +25,3 @@ public struct LoginDataDTO: Decodable { } /// `/api/v1/auth/login/{provider}` 응답 타입 별칭 -public typealias LoginResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Auth/Sources/Model/Login/Mapper/LoginDataDTO+.swift b/Projects/Domain/AuthDomain/Sources/Model/Login/Mapper/LoginDataDTO+.swift similarity index 96% rename from Projects/Data/Auth/Sources/Model/Login/Mapper/LoginDataDTO+.swift rename to Projects/Domain/AuthDomain/Sources/Model/Login/Mapper/LoginDataDTO+.swift index d610d265..08e5470f 100644 --- a/Projects/Data/Auth/Sources/Model/Login/Mapper/LoginDataDTO+.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Login/Mapper/LoginDataDTO+.swift @@ -1,6 +1,6 @@ // // LoginDataDTO+.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // diff --git a/Projects/Data/Auth/Sources/Model/Logout/DTO/LogOutDTO.swift b/Projects/Domain/AuthDomain/Sources/Model/Logout/DTO/LogOutDTO.swift similarity index 93% rename from Projects/Data/Auth/Sources/Model/Logout/DTO/LogOutDTO.swift rename to Projects/Domain/AuthDomain/Sources/Model/Logout/DTO/LogOutDTO.swift index ddb6fb7a..4a15edcc 100644 --- a/Projects/Data/Auth/Sources/Model/Logout/DTO/LogOutDTO.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Logout/DTO/LogOutDTO.swift @@ -1,12 +1,12 @@ // // LogOutDTO.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // +import PickeNetworkInterface import Foundation -import Model public struct LogoutDataDTO: Decodable, Equatable { public let loggedOut: Bool diff --git a/Projects/Data/Auth/Sources/Model/Logout/Mapper/LogOutDTO+.swift b/Projects/Domain/AuthDomain/Sources/Model/Logout/Mapper/LogOutDTO+.swift similarity index 95% rename from Projects/Data/Auth/Sources/Model/Logout/Mapper/LogOutDTO+.swift rename to Projects/Domain/AuthDomain/Sources/Model/Logout/Mapper/LogOutDTO+.swift index 0b5ea884..73f0361f 100644 --- a/Projects/Data/Auth/Sources/Model/Logout/Mapper/LogOutDTO+.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Logout/Mapper/LogOutDTO+.swift @@ -1,6 +1,6 @@ // // LogOutDTO+.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // diff --git a/Projects/Data/Auth/Sources/Model/Token/DTO/TokenDTO.swift b/Projects/Domain/AuthDomain/Sources/Model/Token/DTO/TokenDTO.swift similarity index 80% rename from Projects/Data/Auth/Sources/Model/Token/DTO/TokenDTO.swift rename to Projects/Domain/AuthDomain/Sources/Model/Token/DTO/TokenDTO.swift index 80ee5636..52b1eecb 100644 --- a/Projects/Data/Auth/Sources/Model/Token/DTO/TokenDTO.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Token/DTO/TokenDTO.swift @@ -1,12 +1,12 @@ // // TokenDTO.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // +import PickeNetworkInterface import Foundation -import Model public struct TokenDTO: Decodable { public let accessToken: String @@ -19,4 +19,3 @@ public struct TokenDTO: Decodable { } /// `/api/v1/auth/refresh` 응답 타입 별칭 -public typealias RefreshResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Auth/Sources/Model/Token/Mapper/TokenDTO+.swift b/Projects/Domain/AuthDomain/Sources/Model/Token/Mapper/TokenDTO+.swift similarity index 94% rename from Projects/Data/Auth/Sources/Model/Token/Mapper/TokenDTO+.swift rename to Projects/Domain/AuthDomain/Sources/Model/Token/Mapper/TokenDTO+.swift index 90f16a44..f854c193 100644 --- a/Projects/Data/Auth/Sources/Model/Token/Mapper/TokenDTO+.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Token/Mapper/TokenDTO+.swift @@ -1,6 +1,6 @@ // // TokenDTO+.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // diff --git a/Projects/Data/Auth/Sources/Model/Withdraw/DTO/WithdrawDTO.swift b/Projects/Domain/AuthDomain/Sources/Model/Withdraw/DTO/WithdrawDTO.swift similarity index 92% rename from Projects/Data/Auth/Sources/Model/Withdraw/DTO/WithdrawDTO.swift rename to Projects/Domain/AuthDomain/Sources/Model/Withdraw/DTO/WithdrawDTO.swift index f7989355..cca4e3a3 100644 --- a/Projects/Data/Auth/Sources/Model/Withdraw/DTO/WithdrawDTO.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Withdraw/DTO/WithdrawDTO.swift @@ -1,12 +1,12 @@ // // WithdrawDTO.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // +import PickeNetworkInterface import Foundation -import Model public struct WithdrawDataDTO: Decodable, Equatable { public let withdrawn: Bool diff --git a/Projects/Data/Auth/Sources/Model/Withdraw/Mapper/WithdrawDTO+.swift b/Projects/Domain/AuthDomain/Sources/Model/Withdraw/Mapper/WithdrawDTO+.swift similarity index 96% rename from Projects/Data/Auth/Sources/Model/Withdraw/Mapper/WithdrawDTO+.swift rename to Projects/Domain/AuthDomain/Sources/Model/Withdraw/Mapper/WithdrawDTO+.swift index 719175d8..65453ec8 100644 --- a/Projects/Data/Auth/Sources/Model/Withdraw/Mapper/WithdrawDTO+.swift +++ b/Projects/Domain/AuthDomain/Sources/Model/Withdraw/Mapper/WithdrawDTO+.swift @@ -1,6 +1,6 @@ // // WithdrawDTO+.swift -// Model +// AuthDomain // // Created by Wonji Suh on 5/14/26. // diff --git a/Projects/Domain/Auth/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift b/Projects/Domain/AuthDomain/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift similarity index 80% rename from Projects/Domain/Auth/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift rename to Projects/Domain/AuthDomain/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift index c4051aeb..adc1e59e 100644 --- a/Projects/Domain/Auth/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift +++ b/Projects/Domain/AuthDomain/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift @@ -6,15 +6,15 @@ // import Foundation +import PickeCoreLogger import Dependencies -import LogMacro import AuthenticationServices @preconcurrency import AuthDomainInterface import Sharing public final class AppleOAuthProvider: AppleOAuthProviderInterface, @unchecked Sendable { @Dependency(\.appleOAuthRepository) private var appleRepository: AppleOAuthInterface - @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.userSession) var userSession: UserSession public init() {} public func signInWithCredential( @@ -22,13 +22,13 @@ public final class AppleOAuthProvider: AppleOAuthProviderInterface, @unchecked S nonce: String ) async throws -> AppleOAuthPayload { let payload = try await appleRepository.signInWithCredential(credential, nonce: nonce) - Log.info("Apple sign-in completed through repository with credential") + PickeLogger.info("Apple sign-in completed through repository with credential", category: .auth) return payload } public func signIn() async throws -> AppleOAuthPayload { let payload = try await appleRepository.signIn() - Log.info("Apple sign-in completed through repository (direct)") + PickeLogger.info("Apple sign-in completed through repository (direct)", category: .auth) return payload } diff --git a/Projects/Domain/Auth/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift b/Projects/Domain/AuthDomain/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift similarity index 72% rename from Projects/Domain/Auth/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift rename to Projects/Domain/AuthDomain/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift index f150dea9..d151b190 100644 --- a/Projects/Domain/Auth/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift +++ b/Projects/Domain/AuthDomain/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift @@ -6,21 +6,21 @@ // import Dependencies +import PickeCoreLogger @preconcurrency import AuthDomainInterface 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 + @Shared(.userSession) var userSession: UserSession public init() {} public func signInWithToken(token _: String) async throws -> GoogleOAuthPayload { - Log.info("Starting Google OAuth flow") + PickeLogger.info("Starting Google OAuth flow", category: .auth) let payload = try await googleRepository.signIn() $userSession.withLock { $0.accessToken = payload.accessToken ?? "" } - Log.debug("google authCode", payload.authorizationCode) + PickeLogger.debug("google authCode: \(payload.authorizationCode)", category: .auth) return payload } } diff --git a/Projects/Domain/Auth/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift b/Projects/Domain/AuthDomain/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift similarity index 81% rename from Projects/Domain/Auth/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift rename to Projects/Domain/AuthDomain/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift index 4d3c09f2..8ae5dc8d 100644 --- a/Projects/Domain/Auth/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift +++ b/Projects/Domain/AuthDomain/Sources/OAuth/Provider/Kakao/KakaoOAuthProvider.swift @@ -6,18 +6,18 @@ // import Dependencies +import PickeCoreLogger @preconcurrency import AuthDomainInterface 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 + @Shared(.userSession) var userSession: UserSession public init() {} public func signInWithToken(token _: String) async throws -> KakaoOAuthPayload { - Log.info("Starting Kakao OAuth flow") + PickeLogger.info("Starting Kakao OAuth flow", category: .auth) let payload = try await kakaoRepository.signIn() $userSession.withLock { $0.accessToken = payload.accessToken } return payload diff --git a/Projects/Domain/Auth/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/AuthDomain/Sources/OAuth/UnifiedOAuthUseCase.swift similarity index 80% rename from Projects/Domain/Auth/Sources/OAuth/UnifiedOAuthUseCase.swift rename to Projects/Domain/AuthDomain/Sources/OAuth/UnifiedOAuthUseCase.swift index e6e8cff2..96b23844 100644 --- a/Projects/Domain/Auth/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/AuthDomain/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -7,11 +7,9 @@ @preconcurrency import AuthDomainInterface import AuthenticationServices +import PickeCoreLogger import Dependencies -import DomainInterface -import Entity import Foundation -import LogMacro import Sharing /// 통합 OAuth UseCase — 소셜 인증 → 백엔드 로그인까지 단일 진입점 @@ -20,9 +18,8 @@ public struct UnifiedOAuthUseCase: UnifiedOAuthUseCaseInterface { @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? + @Shared(.userSession) var userSession: UserSession + @Shared(.appleUserName) var savedAppleUserName: String? public init() {} } @@ -68,7 +65,7 @@ public extension UnifiedOAuthUseCase { credential: credential, nonce: nonce ) - Log.debug("apple authcode", payload.authorizationCode) + PickeLogger.debug("apple authcode: \(payload.authorizationCode)", category: .auth) let userName: String = { if let displayName = payload.displayName, !displayName.isEmpty { @@ -87,8 +84,6 @@ public extension UnifiedOAuthUseCase { } let authCode = payload.authorizationCode ?? "" - AuthLocalStorage.authCode = authCode - AuthLocalStorage.idToken = payload.idToken let loginEntity = try await authRepository.login( provider: .apple, @@ -97,11 +92,7 @@ public extension UnifiedOAuthUseCase { idToken: payload.idToken ) - keychainManager.save( - accessToken: loginEntity.token.accessToken, - refreshToken: loginEntity.token.refreshToken - ) - authRepository.updateSessionCredential(with: loginEntity.token) + await authRepository.updateSessionCredential(with: loginEntity.token) return loginEntity } @@ -111,7 +102,7 @@ public extension UnifiedOAuthUseCase { token: String ) async throws -> LoginEntity { let payload = try await googleProvider.signInWithToken(token: token) - Log.debug("google authorizationCode", payload.authorizationCode) + PickeLogger.debug("google authorizationCode: \(payload.authorizationCode)", category: .auth) $userSession.withLock { $0.token = payload.authorizationCode ?? "" @@ -126,11 +117,7 @@ public extension UnifiedOAuthUseCase { idToken: nil ) - keychainManager.save( - accessToken: loginEntity.token.accessToken, - refreshToken: loginEntity.token.refreshToken - ) - authRepository.updateSessionCredential(with: loginEntity.token) + await authRepository.updateSessionCredential(with: loginEntity.token) return loginEntity } @@ -138,7 +125,7 @@ public extension UnifiedOAuthUseCase { /// Kakao 로그인 처리 — picke:// 콜백에서 받은 `code` 를 백엔드에 전달. func kakaoLogin(token: String) async throws -> LoginEntity { let payload = try await kakaoProvider.signInWithToken(token: token) - Log.debug("kakao authorizationCode", payload.authorizationCode) + PickeLogger.debug("kakao authorizationCode: \(payload.authorizationCode)", category: .auth) $userSession.withLock { $0.token = payload.authorizationCode ?? "" @@ -153,11 +140,7 @@ public extension UnifiedOAuthUseCase { idToken: nil ) - keychainManager.save( - accessToken: loginEntity.token.accessToken, - refreshToken: loginEntity.token.refreshToken - ) - authRepository.updateSessionCredential(with: loginEntity.token) + await authRepository.updateSessionCredential(with: loginEntity.token) return loginEntity } diff --git a/Projects/Data/Auth/Sources/Repository/AuthRepositoryImpl.swift b/Projects/Domain/AuthDomain/Sources/Repository/AuthRepositoryImpl.swift similarity index 50% rename from Projects/Data/Auth/Sources/Repository/AuthRepositoryImpl.swift rename to Projects/Domain/AuthDomain/Sources/Repository/AuthRepositoryImpl.swift index 64ad9961..dd3a3ea6 100644 --- a/Projects/Data/Auth/Sources/Repository/AuthRepositoryImpl.swift +++ b/Projects/Domain/AuthDomain/Sources/Repository/AuthRepositoryImpl.swift @@ -6,31 +6,20 @@ // import Foundation +import PickeCoreLogger +import APIEndpoint import AuthDomainInterface -import Entity -import Model -import Repository -import Service +import PickeAuthInterface +import PickeNetwork -import Alamofire import Dependencies -import LogMacro -import WeaveDI public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { - @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.networkClient) private var client + @Dependency(\.authService) private var authService - private let provider: any NetworkProviding - private let authProvider: any NetworkProviding - - public init( - provider: any NetworkProviding = AlamofireNetworkProvider.default, - authProvider: any NetworkProviding = AlamofireNetworkProvider.authorized - ) { - self.provider = provider - self.authProvider = authProvider - } + public init() {} // MARK: - 로그인 @@ -48,49 +37,35 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { if let data = try? JSONEncoder().encode(body), let json = String(data: data, encoding: .utf8) { - Log.debug("[AuthRepository] POST /api/v1/auth/login/\(socialProvider.rawValue) body=\(json)") + PickeLogger.debug("[AuthRepository] POST /api/v1/auth/login/\(socialProvider.rawValue) body=\(json)", category: .auth) } - let dto: LoginResponseDTO = try await provider.request( - .login(provider: socialProvider, body: body) + let data = try await client.send( + AuthService.login(provider: socialProvider, body: body), + as: LoginDataDTO.self ) - 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() ?? "" + let refreshToken = await authService.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() + let data = try await client.send( + AuthService.refresh(refreshToken: refreshToken), + as: TokenDTO.self + ) + return data.toDomain() } catch { - Log.error("🔍 [AuthRepositoryImpl] Refresh failed: \(error)") - - if let afError = error.asAFError, - case let .responseValidationFailed(reason) = afError, - case let .unacceptableStatusCode(code) = reason, - code == 401 - { - throw AuthError.refreshTokenExpired - } + PickeLogger.error("🔍 [AuthRepositoryImpl] Refresh failed: \(error)", category: .auth) - let errorString = String(describing: error) - if errorString.contains("statusCodeError(401)") { + // 서버가 refresh token 을 거부한 경우만 재로그인으로 보낸다(5xx 는 일시 장애). + if case let .response(response) = error, response.isUnauthorized { throw AuthError.refreshTokenExpired } - throw error } } @@ -98,11 +73,11 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { // MARK: - 로그아웃 public func logout() async throws -> AuthExitEntity { - let response = try await authProvider.requestResponse(.logout) + let response = try await client.sendResponse(AuthService.logout) let decoder = JSONDecoder() if (200 ... 299).contains(response.statusCode) { - clearLocalSession() + await authService.signOut() if response.data.isEmpty { return AuthExitEntity(loggedOut: true) } if let success = try? decoder.decode(LogOutDTO.self, from: response.data) { return success.toDomain() @@ -119,7 +94,7 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { // MARK: - 회원 탈퇴 public func withDraw(reason: String) async throws -> WithdrawEntity { - let response = try await authProvider.requestResponse(.withdraw(reason: reason)) + let response = try await client.sendResponse(AuthService.withdraw(reason: reason)) let decoder = JSONDecoder() if (200 ... 299).contains(response.statusCode) { @@ -141,14 +116,11 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { // 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() + /// 로그인 토큰을 Keychain 과 실행 중인 인증 세션에 함께 반영한다. + public func updateSessionCredential(with tokens: AuthTokens) async { + await authService.signIn( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) } } diff --git a/Projects/Data/Auth/Sources/Repository/Google/GoogleOAuthConfiguration.swift b/Projects/Domain/AuthDomain/Sources/Repository/Google/GoogleOAuthConfiguration.swift similarity index 100% rename from Projects/Data/Auth/Sources/Repository/Google/GoogleOAuthConfiguration.swift rename to Projects/Domain/AuthDomain/Sources/Repository/Google/GoogleOAuthConfiguration.swift diff --git a/Projects/Data/Auth/Sources/Repository/Google/GoogleOAuthRepositoryImpl.swift b/Projects/Domain/AuthDomain/Sources/Repository/Google/GoogleOAuthRepositoryImpl.swift similarity index 86% rename from Projects/Data/Auth/Sources/Repository/Google/GoogleOAuthRepositoryImpl.swift rename to Projects/Domain/AuthDomain/Sources/Repository/Google/GoogleOAuthRepositoryImpl.swift index 22b8e574..e0a767da 100644 --- a/Projects/Data/Auth/Sources/Repository/Google/GoogleOAuthRepositoryImpl.swift +++ b/Projects/Domain/AuthDomain/Sources/Repository/Google/GoogleOAuthRepositoryImpl.swift @@ -5,11 +5,10 @@ // Created by Wonji Suh on 12/29/25. // -import AuthenticationServices import AuthDomainInterface -import Entity +import PickeCoreLogger +import AuthenticationServices import Foundation -import LogMacro import UIKit /// Google OAuth — WKWebView 로 authorize URL 띄우고 redirect 콜백을 navigation 단계에서 가로채는 흐름. @@ -28,7 +27,9 @@ public final class GoogleOAuthRepositoryImpl: NSObject, GoogleOAuthInterface { /// DI 호환을 위해 유지 (WKWebView 기반에서는 미사용) private let presentationContextProvider: ASWebAuthenticationPresentationContextProviding - public init(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { + /// 저장 프로퍼티 대입만 하므로 격리가 필요 없다. + /// DependencyKey 의 nonisolated `liveValue` 에서 생성된다. + public nonisolated init(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { self.presentationContextProvider = presentationContextProvider } @@ -40,7 +41,7 @@ public final class GoogleOAuthRepositoryImpl: NSObject, GoogleOAuthInterface { } let authorizeURL = try buildAuthorizeURL(clientID: clientID) - Log.debug("google authorize", authorizeURL.absoluteString) + PickeLogger.debug("google authorize: \(authorizeURL.absoluteString)", category: .auth) let code = try await OAuthWebPresenter.present( authorizeURL: authorizeURL, @@ -48,7 +49,7 @@ public final class GoogleOAuthRepositoryImpl: NSObject, GoogleOAuthInterface { redirectPath: redirectPath, customUserAgent: OAuthWebUserAgent.mobileSafari ) - Log.debug("google authorizationCode", code) + PickeLogger.debug("google authorizationCode: \(code)", category: .auth) return GoogleOAuthPayload( idToken: "", diff --git a/Projects/Data/Auth/Sources/Repository/OAuth/Apple/AppleLoginRepositoryImpl.swift b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Apple/AppleLoginRepositoryImpl.swift similarity index 100% rename from Projects/Data/Auth/Sources/Repository/OAuth/Apple/AppleLoginRepositoryImpl.swift rename to Projects/Domain/AuthDomain/Sources/Repository/OAuth/Apple/AppleLoginRepositoryImpl.swift diff --git a/Projects/Data/Auth/Sources/Repository/OAuth/Apple/AppleOAuthRepositoryImpl.swift b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Apple/AppleOAuthRepositoryImpl.swift similarity index 93% rename from Projects/Data/Auth/Sources/Repository/OAuth/Apple/AppleOAuthRepositoryImpl.swift rename to Projects/Domain/AuthDomain/Sources/Repository/OAuth/Apple/AppleOAuthRepositoryImpl.swift index a540188b..306bfd10 100644 --- a/Projects/Data/Auth/Sources/Repository/OAuth/Apple/AppleOAuthRepositoryImpl.swift +++ b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Apple/AppleOAuthRepositoryImpl.swift @@ -9,20 +9,17 @@ import Foundation import AuthenticationServices @preconcurrency import AuthDomainInterface -import Entity -import LogMacro -import WeaveDI import ComposableArchitecture +import PickeCoreLogger #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? + @Shared(.appleUserName) var appleUserName: String? private var currentNonce: String? private var signInContinuation: CheckedContinuation? @@ -127,7 +124,7 @@ extension AppleOAuthRepositoryImpl: ASAuthorizationControllerDelegate { self.$appleUserName.withLock { $0 = displayName } - logger.info("Apple Sign In successful for user: \(displayName ?? "unknown"), \(appleUserName)") + PickeLogger.info("Apple Sign In 성공 — user: \(displayName ?? "unknown"), 저장된 이름: \(String(describing: appleUserName))", category: .auth) finishSignIn(with: .success(payload)) } @@ -140,7 +137,7 @@ extension AppleOAuthRepositoryImpl: ASAuthorizationControllerDelegate { if nsError.code == ASAuthorizationError.canceled.rawValue { finishSignIn(with: .failure(AuthError.userCancelled)) } else { - logger.error("Apple Sign In failed: \(error.localizedDescription)") + PickeLogger.error("Apple Sign In 실패: \(error.localizedDescription)", category: .auth) finishSignIn(with: .failure(AuthError.invalidCredential(error.localizedDescription))) } } diff --git a/Projects/Domain/AuthDomain/Sources/Repository/OAuth/AuthPresentationContextProvider.swift b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/AuthPresentationContextProvider.swift new file mode 100644 index 00000000..e8a8bbf8 --- /dev/null +++ b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/AuthPresentationContextProvider.swift @@ -0,0 +1,26 @@ +// +// AuthPresentationContextProvider.swift +// AuthDomain +// +// Created by Wonji Suh on 5/14/26. +// + +import AuthenticationServices +import UIKit + +/// 앱 전체에서 사용할 ASWebAuthenticationSession용 presentation provider +public final class AuthPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + override public init() { super.init() } + + public func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: { $0.isKeyWindow }) ?? + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first? + .windows.first ?? + ASPresentationAnchor() + } +} diff --git a/Projects/Data/Auth/Sources/Repository/OAuth/Google/GoogleLoginManager.swift b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Google/GoogleLoginManager.swift similarity index 100% rename from Projects/Data/Auth/Sources/Repository/OAuth/Google/GoogleLoginManager.swift rename to Projects/Domain/AuthDomain/Sources/Repository/OAuth/Google/GoogleLoginManager.swift diff --git a/Projects/Data/Auth/Sources/Repository/OAuth/Kakao/KakaoOAuthRepository.swift b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Kakao/KakaoOAuthRepository.swift similarity index 85% rename from Projects/Data/Auth/Sources/Repository/OAuth/Kakao/KakaoOAuthRepository.swift rename to Projects/Domain/AuthDomain/Sources/Repository/OAuth/Kakao/KakaoOAuthRepository.swift index 7e4eca65..58626901 100644 --- a/Projects/Data/Auth/Sources/Repository/OAuth/Kakao/KakaoOAuthRepository.swift +++ b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Kakao/KakaoOAuthRepository.swift @@ -5,11 +5,10 @@ // Created by Wonji Suh on 12/05/25. // -import AuthenticationServices import AuthDomainInterface -import Entity +import PickeCoreLogger +import AuthenticationServices import Foundation -import LogMacro import UIKit /// Kakao OAuth — WKWebView 로 authorize URL 띄우고 redirect 콜백을 navigation 단계에서 가로채는 흐름. @@ -26,7 +25,9 @@ public final class KakaoOAuthRepository: NSObject, KakaoOAuthInterface { /// DI 호환을 위해 유지 (WKWebView 기반에서는 미사용) private let presentationContextProvider: ASWebAuthenticationPresentationContextProviding - public init(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { + /// 저장 프로퍼티 대입만 하므로 격리가 필요 없다. + /// DependencyKey 의 nonisolated `liveValue` 에서 생성된다. + public nonisolated init(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { self.presentationContextProvider = presentationContextProvider } @@ -38,7 +39,7 @@ public final class KakaoOAuthRepository: NSObject, KakaoOAuthInterface { } let authorizeURL = try buildAuthorizeURL(clientID: clientID) - Log.debug("kakao authorize", authorizeURL.absoluteString) + PickeLogger.debug("kakao authorize: \(authorizeURL.absoluteString)", category: .auth) let code = try await OAuthWebPresenter.present( authorizeURL: authorizeURL, @@ -46,7 +47,7 @@ public final class KakaoOAuthRepository: NSObject, KakaoOAuthInterface { redirectPath: redirectPath, usesEphemeralSession: true ) - Log.debug("kakao authorizationCode", code) + PickeLogger.debug("kakao authorizationCode: \(code)", category: .auth) return KakaoOAuthPayload( idToken: "", diff --git a/Projects/Data/Auth/Sources/Repository/OAuth/Web/OAuthWebViewController.swift b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Web/OAuthWebViewController.swift similarity index 99% rename from Projects/Data/Auth/Sources/Repository/OAuth/Web/OAuthWebViewController.swift rename to Projects/Domain/AuthDomain/Sources/Repository/OAuth/Web/OAuthWebViewController.swift index 5d6b81fc..45f9d310 100644 --- a/Projects/Data/Auth/Sources/Repository/OAuth/Web/OAuthWebViewController.swift +++ b/Projects/Domain/AuthDomain/Sources/Repository/OAuth/Web/OAuthWebViewController.swift @@ -7,7 +7,6 @@ import Combine import AuthDomainInterface -import Entity import Foundation import UIKit import WebKit diff --git a/Projects/Domain/Auth/Testing/MockAuthRepository.swift b/Projects/Domain/AuthDomain/Testing/MockAuthRepository.swift similarity index 98% rename from Projects/Domain/Auth/Testing/MockAuthRepository.swift rename to Projects/Domain/AuthDomain/Testing/MockAuthRepository.swift index 4538caf5..459b5135 100644 --- a/Projects/Domain/Auth/Testing/MockAuthRepository.swift +++ b/Projects/Domain/AuthDomain/Testing/MockAuthRepository.swift @@ -135,7 +135,7 @@ public final class MockAuthRepository: AuthInterface, @unchecked Sendable { } } - public func updateSessionCredential(with tokens: AuthTokens) { + public func updateSessionCredential(with tokens: AuthTokens) async { updateCredentialCallCount += 1 lastUpdatedTokens = tokens } diff --git a/Projects/Domain/Auth/Tests/AuthDomainTests.swift b/Projects/Domain/AuthDomain/Tests/Sources/AuthDomainTests.swift similarity index 100% rename from Projects/Domain/Auth/Tests/AuthDomainTests.swift rename to Projects/Domain/AuthDomain/Tests/Sources/AuthDomainTests.swift diff --git a/Projects/Data/Auth/Tests/AuthRepositoryTests.swift b/Projects/Domain/AuthDomain/Tests/Sources/AuthRepositoryTests.swift similarity index 50% rename from Projects/Data/Auth/Tests/AuthRepositoryTests.swift rename to Projects/Domain/AuthDomain/Tests/Sources/AuthRepositoryTests.swift index 98540b72..1bfa1360 100644 --- a/Projects/Data/Auth/Tests/AuthRepositoryTests.swift +++ b/Projects/Domain/AuthDomain/Tests/Sources/AuthRepositoryTests.swift @@ -4,12 +4,15 @@ // import Foundation + +import Dependencies import Testing -@testable import AuthData +@testable import AuthDomain +import APIEndpoint import AuthDomainInterface -import Entity +import PickeAuthInterface struct AuthRepositoryTests { // MARK: - login @@ -30,10 +33,12 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: StubNetworkProvider(stubData: fixture), - authProvider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.authService = RecordingAuthService(refreshToken: "refresh-1") + $0.networkClient = StubNetworkClient(stubData: fixture) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.login( provider: .kakao, @@ -51,7 +56,7 @@ struct AuthRepositoryTests { } @Test - func login_emptyData_throwsBackendError() async throws { + func login_errorEnvelope_throwsNetworkResponseError() async throws { let fixture = Data(""" { "statusCode": 400, @@ -60,23 +65,24 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: StubNetworkProvider(stubData: fixture), - authProvider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.authService = RecordingAuthService(refreshToken: "refresh-1") + $0.networkClient = StubNetworkClient(stubData: fixture) + } operation: { + AuthRepositoryImpl() + } - do { + await expectNetworkResponseError( + statusCode: 400, + code: "AUTH_400", + message: "로그인에 실패했습니다" + ) { _ = try await repo.login( provider: .kakao, authorizationCode: "code-kakao", redirectUri: nil, idToken: nil ) - Issue.record("로그인 실패 응답인데 에러가 던져지지 않았습니다") - } catch let error as AuthError { - #expect(error == .backendError("로그인에 실패했습니다")) - } catch { - Issue.record("예상치 못한 에러 타입: \(error)") } } @@ -95,10 +101,12 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: StubNetworkProvider(stubData: fixture), - authProvider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.authService = RecordingAuthService(refreshToken: "refresh-1") + $0.networkClient = StubNetworkClient(stubData: fixture) + } operation: { + AuthRepositoryImpl() + } let tokens = try await repo.refresh() @@ -107,7 +115,7 @@ struct AuthRepositoryTests { } @Test - func refresh_emptyData_throwsBackendError() async throws { + func refresh_errorEnvelope_throwsNetworkResponseError() async throws { let fixture = Data(""" { "statusCode": 400, @@ -116,35 +124,35 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: StubNetworkProvider(stubData: fixture), - authProvider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.authService = RecordingAuthService(refreshToken: "refresh-1") + $0.networkClient = StubNetworkClient(stubData: fixture) + } operation: { + AuthRepositoryImpl() + } - do { + await expectNetworkResponseError( + statusCode: 400, + code: "AUTH_401", + message: "토큰 재발급에 실패했습니다" + ) { _ = try await repo.refresh() - Issue.record("토큰 재발급 실패 응답인데 에러가 던져지지 않았습니다") - } catch let error as AuthError { - #expect(error == .backendError("토큰 재발급에 실패했습니다")) - } catch { - Issue.record("예상치 못한 에러 타입: \(error)") } } @Test func refresh_providerThrows_rethrowsOriginalError() async throws { - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.authService = RecordingAuthService(refreshToken: "refresh-1") + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + AuthRepositoryImpl() + } - do { + await expectNetworkTransportError( + underlyingError: ThrowingStubNetworkClient.StubError.self + ) { _ = try await repo.refresh() - Issue.record("provider 가 에러를 던졌는데 refresh() 가 에러를 던지지 않았습니다") - } catch is ThrowingStubNetworkProvider.StubError { - // AFError 도, "statusCodeError(401)" 문자열도 아니므로 원본 에러가 그대로 다시 던져져야 한다. - } catch { - Issue.record("예상치 못한 에러 타입: \(error)") } } @@ -160,10 +168,12 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: StubNetworkProvider(stubData: fixture, statusCode: 200) - ) + let repo = withDependencies { + $0.authService = RecordingAuthService() + $0.networkClient = StubNetworkClient(stubData: fixture, statusCode: 200) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.logout() @@ -174,10 +184,12 @@ struct AuthRepositoryTests { @Test func logout_emptyBody_defaultsToLoggedOutTrue() async throws { - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: StubNetworkProvider(stubData: Data(), statusCode: 200) - ) + let repo = withDependencies { + $0.authService = RecordingAuthService() + $0.networkClient = StubNetworkClient(stubData: Data(), statusCode: 200) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.logout() @@ -194,10 +206,11 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: StubNetworkProvider(stubData: fixture, statusCode: 500) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: fixture, statusCode: 500) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.logout() @@ -218,10 +231,11 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: StubNetworkProvider(stubData: fixture, statusCode: 200) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: fixture, statusCode: 200) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.withDraw(reason: "NOT_USED_OFTEN") @@ -232,10 +246,11 @@ struct AuthRepositoryTests { @Test func withDraw_emptyBody_defaultsToSuccessTrue() async throws { - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: StubNetworkProvider(stubData: Data(), statusCode: 200) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(), statusCode: 200) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.withDraw(reason: "NOT_USED_OFTEN") @@ -253,10 +268,11 @@ struct AuthRepositoryTests { } """.utf8) - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: StubNetworkProvider(stubData: fixture, statusCode: 403) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: fixture, statusCode: 403) + } operation: { + AuthRepositoryImpl() + } let entity = try await repo.withDraw(reason: "NOT_USED_OFTEN") @@ -268,12 +284,56 @@ struct AuthRepositoryTests { // MARK: - updateSessionCredential @Test - func updateSessionCredential_doesNotCrash() { - let repo = AuthRepositoryImpl( - provider: ThrowingStubNetworkProvider(), - authProvider: ThrowingStubNetworkProvider() + func updateSessionCredential_passesTokensToAuthService() async { + let authService = RecordingAuthService() + let repo = withDependencies { + $0.authService = authService + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + AuthRepositoryImpl() + } + + await repo.updateSessionCredential( + with: AuthTokens(accessToken: "access-token", refreshToken: "refresh-token") ) - repo.updateSessionCredential(with: AuthTokens(accessToken: "a", refreshToken: "r")) + let tokens = await authService.recordedTokens() + #expect(tokens?.accessToken == "access-token") + #expect(tokens?.refreshToken == "refresh-token") + } +} + +private actor RecordingAuthService: PickeAuthInterface.AuthService { + private var accessToken: String? + private var storedRefreshToken: String? + + init(refreshToken: String? = nil) { + storedRefreshToken = refreshToken + } + + var isLoggedIn: Bool { + get async { accessToken != nil && storedRefreshToken != nil } + } + + var refreshToken: String? { + get async { storedRefreshToken } + } + + func signIn( + accessToken: String, + refreshToken: String + ) async { + self.accessToken = accessToken + storedRefreshToken = refreshToken + } + + func signOut() async { + accessToken = nil + storedRefreshToken = nil + } + + func recordedTokens() -> AuthTokens? { + guard let accessToken, let storedRefreshToken else { return nil } + return AuthTokens(accessToken: accessToken, refreshToken: storedRefreshToken) } } diff --git a/Projects/Data/Auth/Tests/AuthRequestMappingTests.swift b/Projects/Domain/AuthDomain/Tests/Sources/AuthRequestMappingTests.swift similarity index 73% rename from Projects/Data/Auth/Tests/AuthRequestMappingTests.swift rename to Projects/Domain/AuthDomain/Tests/Sources/AuthRequestMappingTests.swift index cf74aeeb..9238a8ac 100644 --- a/Projects/Data/Auth/Tests/AuthRequestMappingTests.swift +++ b/Projects/Domain/AuthDomain/Tests/Sources/AuthRequestMappingTests.swift @@ -6,17 +6,18 @@ import Foundation import Testing -@testable import AuthData +@testable import AuthDomain import API +import APIEndpoint import AuthDomainInterface -import NetworkHeader +@testable import PickeNetwork struct AuthRequestMappingTests { // MARK: - login @Test - func login_kakao_urlPath_matchesAuthAPIDescriptionPlusProvider() { + func login_kakao_path_matchesAuthAPIDescriptionPlusProvider() { let body = OAuthLoginRequest( authorizationCode: "code-kakao", redirectUri: "https://picke.store/oauth/kakao", @@ -24,8 +25,8 @@ struct AuthRequestMappingTests { ) let service = AuthService.login(provider: .kakao, body: body) - #expect(service.urlPath == "\(AuthAPI.login.description)/\(SocialType.kakao.rawValue)") - #expect(service.urlPath == "login/kakao") + #expect(service.path == "\(AuthAPI.login.description)/\(SocialType.kakao.rawValue)") + #expect(service.path == "login/kakao") } @Test @@ -64,24 +65,24 @@ struct AuthRequestMappingTests { } @Test - func login_request_usesNotAccessTokenHeader() throws { + func login_request_usesNoAuthorizationPolicy() throws { let body = OAuthLoginRequest(authorizationCode: "code", redirectUri: nil, idToken: nil) let service = AuthService.login(provider: .google, body: body) let request = try service.asURLRequest() + #expect(service.authorization == .none) #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - #expect(request.value(forHTTPHeaderField: "accept") == "application/json") #expect(request.value(forHTTPHeaderField: "Authorization") == nil) } // MARK: - refresh @Test - func refresh_urlPath_matchesAuthAPIDescription() { + func refresh_path_matchesAuthAPIDescription() { let service = AuthService.refresh(refreshToken: "refresh-abc") - #expect(service.urlPath == AuthAPI.refresh.description) - #expect(service.urlPath == "refresh") + #expect(service.path == AuthAPI.refresh.description) + #expect(service.path == "refresh") } @Test @@ -92,23 +93,23 @@ struct AuthRequestMappingTests { #expect(service.method == .post) #expect(request.url?.path == "/api/v1/auth/refresh") #expect(request.httpBody == nil) + #expect(service.authorization == .none) #expect(request.value(forHTTPHeaderField: "X-Refresh-Token") == "refresh-abc") - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") #expect(request.value(forHTTPHeaderField: "Authorization") == nil) } // MARK: - withdraw @Test - func withdraw_urlPath_matchesAuthAPIDescription() { + func withdraw_path_matchesAuthAPIDescription() { let service = AuthService.withdraw(reason: "NOT_USED_OFTEN") - #expect(service.urlPath == AuthAPI.withDraw.description) - #expect(service.urlPath == "") + #expect(service.path == AuthAPI.withDraw.description) + #expect(service.path == "") } @Test - func withdraw_request_isDELETEWithJSONBodyAndBaseHeader() throws { + func withdraw_request_isDELETEWithJSONBodyAndAutomaticAuthorization() throws { let service = AuthService.withdraw(reason: "NOT_USED_OFTEN") let request = try service.asURLRequest() @@ -119,29 +120,28 @@ struct AuthRequestMappingTests { let json = try #require(JSONSerialization.jsonObject(with: httpBody) as? [String: Any]) #expect(json["reason"] as? String == "NOT_USED_OFTEN") + #expect(service.authorization == .automatic) #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true) } // MARK: - logout @Test - func logout_urlPath_matchesAuthAPIDescription() { + func logout_path_matchesAuthAPIDescription() { let service = AuthService.logout - #expect(service.urlPath == AuthAPI.logout.description) - #expect(service.urlPath == "logout") + #expect(service.path == AuthAPI.logout.description) + #expect(service.path == "logout") } @Test - func logout_request_isPOSTWithNoBodyAndBaseHeader() throws { + func logout_request_isPOSTWithNoBodyAndAutomaticAuthorization() throws { let service = AuthService.logout let request = try service.asURLRequest() #expect(service.method == .post) #expect(request.url?.path == "/api/v1/auth/logout") #expect(request.httpBody == nil) - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true) + #expect(service.authorization == .automatic) } } diff --git a/Projects/Domain/AuthDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/AuthDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..549c72fe --- /dev/null +++ b/Projects/Domain/AuthDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,145 @@ +// +// TestSupport.swift +// AuthDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} + +func expectNetworkTransportError( + underlyingError _: UnderlyingError.Type, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("transport 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .transport(.unknown(underlying)) = error else { + Issue.record("transport 에러여야 합니다: \(error)") + return + } + #expect(underlying is UnderlyingError) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Battle/Testing/BattleDomainTesting.swift b/Projects/Domain/Battle/Testing/BattleDomainTesting.swift deleted file mode 100644 index 667bd89d..00000000 --- a/Projects/Domain/Battle/Testing/BattleDomainTesting.swift +++ /dev/null @@ -1,4 +0,0 @@ -import BattleDomainInterface - -/// Battle 도메인 목(mock) 배치용 네임스페이스. (현재 목 없음) -public enum BattleDomainTesting {} diff --git a/Projects/Domain/Battle/Interface/Contract/Interface/BattleInterface.swift b/Projects/Domain/BattleDomain/Interface/Sources/Contract/BattleInterface.swift similarity index 75% rename from Projects/Domain/Battle/Interface/Contract/Interface/BattleInterface.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Contract/BattleInterface.swift index 7cd20e06..d5c71529 100644 --- a/Projects/Domain/Battle/Interface/Contract/Interface/BattleInterface.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Contract/BattleInterface.swift @@ -3,11 +3,9 @@ // DomainInterface // -import CommonDomainInterface -import Entity import Foundation import HomeDomainInterface -import WeaveDI +import ComposableArchitecture public protocol BattleInterface: Sendable { func fetchTodayBattles() async throws -> TodayBattlePage @@ -40,16 +38,12 @@ public protocol BattleInterface: Sendable { func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal } -public struct BattleRepositoryDependency: DependencyKey { - public static var liveValue: BattleInterface { - UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() - } - - public static var testValue: BattleInterface { - UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() - } +public enum BattleRepositoryDependency: TestDependencyKey { + public static var testValue: BattleInterface { MockBattleRepository() } +} - public static var previewValue: BattleInterface = liveValue +public enum BattleUseCaseDependency: TestDependencyKey { + public static var testValue: BattleInterface { MockBattleRepository() } } public extension DependencyValues { @@ -62,7 +56,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var battleUseCase: BattleInterface { - get { self[BattleRepositoryDependency.self] } - set { self[BattleRepositoryDependency.self] = newValue } + get { self[BattleUseCaseDependency.self] } + set { self[BattleUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/Battle/Interface/Contract/Default/DefaultBattleRepositoryImpl.swift b/Projects/Domain/BattleDomain/Interface/Sources/Contract/Default/MockBattleRepository.swift similarity index 95% rename from Projects/Domain/Battle/Interface/Contract/Default/DefaultBattleRepositoryImpl.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Contract/Default/MockBattleRepository.swift index 43bf53ae..d5099d42 100644 --- a/Projects/Domain/Battle/Interface/Contract/Default/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Contract/Default/MockBattleRepository.swift @@ -1,14 +1,12 @@ // -// DefaultBattleRepositoryImpl.swift +// MockBattleRepository.swift // DomainInterface // -import CommonDomainInterface -import Entity import Foundation import HomeDomainInterface -public struct DefaultBattleRepositoryImpl: BattleInterface { +public struct MockBattleRepository: BattleInterface { public init() {} public func fetchTodayBattles() async throws -> TodayBattlePage { diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleDetail.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleDetail.swift similarity index 97% rename from Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleDetail.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleDetail.swift index a9294f51..a6eeec42 100644 --- a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleDetail.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleDetail.swift @@ -4,7 +4,7 @@ // import Foundation -import CommonDomainInterface +import HomeDomainInterface public struct BattleDetail: Equatable, Identifiable { public let battleInfo: BattleInfo diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleInfo.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleInfo.swift similarity index 97% rename from Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleInfo.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleInfo.swift index c8408442..9a7399ee 100644 --- a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleInfo.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleInfo.swift @@ -4,7 +4,7 @@ // import Foundation -import CommonDomainInterface +import HomeDomainInterface public struct BattleInfo: Equatable, Identifiable { public let battleId: Int diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleOption.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleOption.swift similarity index 96% rename from Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleOption.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleOption.swift index 291bcc91..aa4e7b42 100644 --- a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleOption.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleOption.swift @@ -4,7 +4,7 @@ // import Foundation -import CommonDomainInterface +import HomeDomainInterface public struct BattleOption: Equatable, Identifiable, Hashable { public let optionId: Int diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleStep.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleStep.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/Detail/BattleStep.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/BattleStep.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Detail/UserVoteStatus.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/UserVoteStatus.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/Detail/UserVoteStatus.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Detail/UserVoteStatus.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PhilosopherAvatar.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PhilosopherAvatar.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PhilosopherAvatar.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PhilosopherAvatar.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteBattle.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteBattle.swift similarity index 95% rename from Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteBattle.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteBattle.swift index 8c8a744d..bcd3ba62 100644 --- a/Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteBattle.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteBattle.swift @@ -9,7 +9,6 @@ import Foundation import HomeDomainInterface /// 사전 투표창 (.pen `U5WO4`) 에 표시되는 배틀 모델. -/// 홈 카드의 `VoteQuestion` 과 달리 2지선다 + 철학자 아바타 기반. public struct PreVoteBattle: Equatable, Identifiable { public let battleId: Int public let backgroundImageURL: String? diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteOption.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteOption.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteOption.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteOption.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteResult.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteResult.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteResult.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteResult.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteResultStatus.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteResultStatus.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/PreVote/PreVoteResultStatus.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/PreVote/PreVoteResultStatus.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattle.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift similarity index 96% rename from Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattle.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift index 28c99355..f6e41919 100644 --- a/Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattle.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift @@ -4,7 +4,6 @@ // import Foundation -import CommonDomainInterface public struct RecommendedBattle: Equatable, Identifiable, Hashable { public let battleId: Int diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattleOption.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattleOption.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattleOption.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattleOption.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattlePage.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattlePage.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattlePage.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattlePage.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattleTag.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattleTag.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/Recommend/RecommendedBattleTag.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattleTag.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/BattleVoteStats.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/BattleVoteStats.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/BattleVoteStats.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/BattleVoteStats.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/BattleVoteStatsOption.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/BattleVoteStatsOption.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/BattleVoteStatsOption.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/BattleVoteStatsOption.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/VoteOptionSummary.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteOptionSummary.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/VoteOptionSummary.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteOptionSummary.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/VoteSummary.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteSummary.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Battle/VoteStats/VoteSummary.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteSummary.swift diff --git a/Projects/Domain/Entity/Sources/Error/BattleError.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/BattleError.swift similarity index 98% rename from Projects/Domain/Entity/Sources/Error/BattleError.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/BattleError.swift index fe76e10a..a9004c3d 100644 --- a/Projects/Domain/Entity/Sources/Error/BattleError.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/BattleError.swift @@ -1,6 +1,6 @@ // // BattleError.swift -// Entity +// BattleDomainInterface // import Foundation diff --git a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspective.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspective.swift similarity index 97% rename from Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspective.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspective.swift index 8712977c..a1012d56 100644 --- a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspective.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspective.swift @@ -1,6 +1,6 @@ // // BattlePerspective.swift -// Entity +// BattleDomainInterface // import Foundation diff --git a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveOption.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveOption.swift similarity index 94% rename from Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveOption.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveOption.swift index 0c909c21..45dad80a 100644 --- a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveOption.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveOption.swift @@ -1,6 +1,6 @@ // // BattlePerspectiveOption.swift -// Entity +// BattleDomainInterface // import Foundation diff --git a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectivePage.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectivePage.swift similarity index 93% rename from Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectivePage.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectivePage.swift index 19ccb409..704756a5 100644 --- a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectivePage.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectivePage.swift @@ -1,6 +1,6 @@ // // BattlePerspectivePage.swift -// Entity +// BattleDomainInterface // import Foundation diff --git a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveSort.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveSort.swift similarity index 92% rename from Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveSort.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveSort.swift index b54f2951..da27e3a6 100644 --- a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveSort.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveSort.swift @@ -1,6 +1,6 @@ // // BattlePerspectiveSort.swift -// Entity +// BattleDomainInterface // import Foundation diff --git a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveUser.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveUser.swift similarity index 95% rename from Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveUser.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveUser.swift index cb56a35b..a4863bce 100644 --- a/Projects/Domain/Common/Interface/Entity/Perspective/BattlePerspectiveUser.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Perspective/BattlePerspectiveUser.swift @@ -1,6 +1,6 @@ // // BattlePerspectiveUser.swift -// Entity +// BattleDomainInterface // import Foundation diff --git a/Projects/Domain/Battle/Interface/Entity/Proposal/BattleProposal.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Proposal/BattleProposal.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Proposal/BattleProposal.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Proposal/BattleProposal.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Proposal/BattleProposalCategory.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Proposal/BattleProposalCategory.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Proposal/BattleProposalCategory.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Proposal/BattleProposalCategory.swift diff --git a/Projects/Domain/Battle/Interface/Entity/Proposal/BattleProposalDraft.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Proposal/BattleProposalDraft.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/Proposal/BattleProposalDraft.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/Proposal/BattleProposalDraft.swift diff --git a/Projects/Domain/Battle/Interface/Entity/TodayBattlePage.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/TodayBattlePage.swift similarity index 100% rename from Projects/Domain/Battle/Interface/Entity/TodayBattlePage.swift rename to Projects/Domain/BattleDomain/Interface/Sources/Entity/TodayBattlePage.swift diff --git a/Projects/Domain/Battle/Project.swift b/Projects/Domain/BattleDomain/Project.swift similarity index 55% rename from Projects/Domain/Battle/Project.swift rename to Projects/Domain/BattleDomain/Project.swift index de5d2448..c372789f 100644 --- a/Projects/Domain/Battle/Project.swift +++ b/Projects/Domain/BattleDomain/Project.swift @@ -1,23 +1,26 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .microModule(name: "BattleDomain"), +import ProjectDescription + +let project = Project.makeModule( + name: "BattleDomain", bundleId: .appBundleID(name: ".BattleDomain"), + product: .framework, settings: .settings(), dependencies: [ - .Domain(.Common, .interface), - .Domain(implements: .Entity), - .SPM.weaveDI, + .serviceAssembly, .SPM.composableArchitecture, + .domain(.home, .interface), ], + hasTests: true, + hasInterface: true, interfaceDependencies: [ - .Domain(.Common, .interface), - .Domain(implements: .Entity), - .SPM.weaveDI, + .domain(.home, .interface), .SPM.composableArchitecture, - ] -) + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Domain/Battle/Sources/Battle/BattleUseCase.swift b/Projects/Domain/BattleDomain/Sources/Battle/BattleUseCase.swift similarity index 97% rename from Projects/Domain/Battle/Sources/Battle/BattleUseCase.swift rename to Projects/Domain/BattleDomain/Sources/Battle/BattleUseCase.swift index a93ef784..bbad994e 100644 --- a/Projects/Domain/Battle/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/BattleDomain/Sources/Battle/BattleUseCase.swift @@ -6,9 +6,6 @@ import Foundation import BattleDomainInterface -import CommonDomainInterface -import DomainInterface -import Entity import HomeDomainInterface import ComposableArchitecture diff --git a/Projects/Domain/BattleDomain/Sources/BattleLiveDependencies.swift b/Projects/Domain/BattleDomain/Sources/BattleLiveDependencies.swift new file mode 100644 index 00000000..101c2591 --- /dev/null +++ b/Projects/Domain/BattleDomain/Sources/BattleLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// BattleLiveDependencies.swift +// BattleDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import BattleDomainInterface +import ComposableArchitecture + +extension BattleUseCaseDependency: DependencyKey { + public static var liveValue: BattleInterface { BattleUseCaseImpl() } +} + +extension BattleRepositoryDependency: DependencyKey { + public static var liveValue: BattleInterface { BattleRepositoryImpl() } +} diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleDetailDataDTO.swift similarity index 97% rename from Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/BattleDetailDataDTO.swift index adbe6de3..62e6bf00 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleDetailDataDTO.swift @@ -1,10 +1,11 @@ // // BattleDetailDataDTO.swift -// Model +// BattleDomain // import Foundation -import CommonDomainInterface + +import PickeNetworkInterface public struct BattleDetailDataDTO: Decodable { public let battleInfo: BattleInfoDTO @@ -97,5 +98,3 @@ public struct BattleTagDTO: Decodable { public let name: String public let type: String } - -public typealias BattleDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattlePerspectiveDataDTO.swift similarity index 84% rename from Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/BattlePerspectiveDataDTO.swift index 64b46e64..c179deaa 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattlePerspectiveDataDTO.swift @@ -1,10 +1,12 @@ // // BattlePerspectiveDataDTO.swift -// Model +// BattleDomain // import Foundation +import PickeNetworkInterface + public struct BattlePerspectivePageDataDTO: Decodable { public let items: [BattlePerspectiveDTO] public let nextCursor: String? @@ -43,5 +45,3 @@ public struct CreatePerspectiveDataDTO: Decodable { public let createdAt: String? } -public typealias BattlePerspectivePageResponseDTO = BaseResponseDTO -public typealias CreatePerspectiveResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleProposalDataDTO.swift similarity index 81% rename from Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/BattleProposalDataDTO.swift index 0a337802..d03db82a 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleProposalDataDTO.swift @@ -1,10 +1,12 @@ // // BattleProposalDataDTO.swift -// Model +// BattleDomain // import Foundation +import PickeNetworkInterface + public struct BattleProposalDataDTO: Decodable { public let id: Int? public let userId: Int? @@ -17,5 +19,3 @@ public struct BattleProposalDataDTO: Decodable { public let status: String? public let createdAt: String? } - -public typealias BattleProposalResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift similarity index 96% rename from Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift index bcd20272..9ac93c77 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift @@ -1,10 +1,12 @@ // // BattleScenarioDataDTO.swift -// Model +// BattleDomain // import Foundation +import PickeNetworkInterface + public struct BattleScenarioDataDTO: Decodable { public let battleId: Int public let title: String @@ -76,5 +78,3 @@ public struct ScenarioInteractiveOptionDTO: Decodable { public let label: String public let nextNodeId: Int } - -public typealias BattleScenarioResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleVoteStatsDataDTO.swift similarity index 84% rename from Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/BattleVoteStatsDataDTO.swift index bc7d0369..13a300b6 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleVoteStatsDataDTO.swift @@ -1,10 +1,12 @@ // // BattleVoteStatsDataDTO.swift -// Model +// BattleDomain // import Foundation +import PickeNetworkInterface + public struct BattleVoteStatsDataDTO: Decodable { public let options: [BattleVoteStatsOptionDTO] public let totalCount: Int @@ -21,5 +23,3 @@ public struct BattleVoteStatsOptionDTO: Decodable { public let stance: String? public let imageUrl: String? } - -public typealias BattleVoteStatsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/PreVoteDataDTO.swift similarity index 65% rename from Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/PreVoteDataDTO.swift index 56b3aa03..2e4a60d6 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/PreVoteDataDTO.swift @@ -1,13 +1,13 @@ // // PreVoteDataDTO.swift -// Model +// BattleDomain // import Foundation +import PickeNetworkInterface + public struct PreVoteDataDTO: Decodable { public let voteId: Int public let status: String } - -public typealias PreVoteResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/RecommendedBattleDataDTO.swift similarity index 86% rename from Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/RecommendedBattleDataDTO.swift index a8756858..714737f9 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/RecommendedBattleDataDTO.swift @@ -1,10 +1,11 @@ // // RecommendedBattleDataDTO.swift -// Model +// BattleDomain // import Foundation -import CommonDomainInterface + +import PickeNetworkInterface public struct RecommendedBattlePageDataDTO: Decodable { public let items: [RecommendedBattleDTO] @@ -35,5 +36,3 @@ public struct RecommendedBattleOptionDTO: Decodable { public let representative: String? public let imageUrl: String? } - -public typealias RecommendedBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/TodayBattleDataDTO.swift similarity index 64% rename from Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift rename to Projects/Domain/BattleDomain/Sources/Model/DTO/TodayBattleDataDTO.swift index 594597cc..9afcfd85 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/TodayBattleDataDTO.swift @@ -1,13 +1,13 @@ // // TodayBattleDataDTO.swift -// Model +// BattleDomain // import Foundation +import PickeNetworkInterface + public struct TodayBattlePageDataDTO: Decodable { public let items: [BattleInfoDTO] public let totalCount: Int } - -public typealias TodayBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleDetailDataDTO+.swift similarity index 96% rename from Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleDetailDataDTO+.swift index 2818f8be..c00bbead 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleDetailDataDTO+.swift @@ -1,12 +1,11 @@ // // BattleDetailDataDTO+.swift -// Model +// BattleDomain // -import Entity import BattleDomainInterface -import CommonDomainInterface import Foundation +import HomeDomainInterface public extension BattleDetailDataDTO { func toDomain() -> BattleDetail { diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattlePerspectiveDataDTO+.swift similarity index 92% rename from Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/BattlePerspectiveDataDTO+.swift index 1ee58f02..eb412e14 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattlePerspectiveDataDTO+.swift @@ -1,11 +1,11 @@ // // BattlePerspectiveDataDTO+.swift -// Model +// BattleDomain // -import CommonDomainInterface -import Entity +import BattleDomainInterface import Foundation +import PickeCoreUtility public extension BattlePerspectivePageDataDTO { func toDomain() -> BattlePerspectivePage { @@ -33,7 +33,7 @@ public extension BattlePerspectiveDTO { } private static func parseISO8601(_ value: String) -> Date? { - PerspectiveDateParser.parse(value) + ServerDateParser.parse(value) } } diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleProposalDataDTO+.swift similarity index 97% rename from Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleProposalDataDTO+.swift index e49609d5..8490f256 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleProposalDataDTO+.swift @@ -1,9 +1,8 @@ // // BattleProposalDataDTO+.swift -// Model +// BattleDomain // -import Entity import BattleDomainInterface import Foundation diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift similarity index 98% rename from Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift index d26e8198..1dc41416 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift @@ -1,9 +1,8 @@ // // BattleScenarioDataDTO+.swift -// Model +// BattleDomain // -import Entity import Foundation import HomeDomainInterface diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleVoteStatsDataDTO+.swift similarity index 97% rename from Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleVoteStatsDataDTO+.swift index 1c02f65a..879fe9d5 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleVoteStatsDataDTO+.swift @@ -1,9 +1,8 @@ // // BattleVoteStatsDataDTO+.swift -// Model +// BattleDomain // -import Entity import BattleDomainInterface import Foundation diff --git a/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/PreVoteDataDTO+.swift similarity index 91% rename from Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/PreVoteDataDTO+.swift index 9c435db2..72489fb1 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/PreVoteDataDTO+.swift @@ -1,9 +1,8 @@ // // PreVoteDataDTO+.swift -// Model +// BattleDomain // -import Entity import BattleDomainInterface import Foundation diff --git a/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/RecommendedBattleDataDTO+.swift similarity index 95% rename from Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/RecommendedBattleDataDTO+.swift index 7dc24eee..3c7c4721 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/RecommendedBattleDataDTO+.swift @@ -1,11 +1,9 @@ // // RecommendedBattleDataDTO+.swift -// Model +// BattleDomain // -import Entity import BattleDomainInterface -import CommonDomainInterface import Foundation public extension RecommendedBattlePageDataDTO { diff --git a/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/TodayBattleDataDTO+.swift similarity index 91% rename from Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift rename to Projects/Domain/BattleDomain/Sources/Model/Mapper/TodayBattleDataDTO+.swift index 9aed8152..cc49c36f 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/TodayBattleDataDTO+.swift @@ -1,9 +1,8 @@ // // TodayBattleDataDTO+.swift -// Model +// BattleDomain // -import Entity import BattleDomainInterface import Foundation diff --git a/Projects/Domain/BattleDomain/Sources/Repository/BattleRepositoryImpl.swift b/Projects/Domain/BattleDomain/Sources/Repository/BattleRepositoryImpl.swift new file mode 100644 index 00000000..fbccf950 --- /dev/null +++ b/Projects/Domain/BattleDomain/Sources/Repository/BattleRepositoryImpl.swift @@ -0,0 +1,160 @@ +// +// BattleRepositoryImpl.swift +// Repository +// + +import Foundation +import PickeCoreLogger + +import Dependencies + +import APIEndpoint +import BattleDomainInterface +import HomeDomainInterface +import PickeNetwork + + +public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func fetchTodayBattles() async throws -> TodayBattlePage { + let data = try await client.send( + BattleService.today, + as: TodayBattlePageDataDTO.self + ) + + return data.toDomain() + } + + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + let data = try await client.send( + BattleService.detail(battleId: battleId), + as: BattleDetailDataDTO.self + ) + + return data.toDomain() + } + + public func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { + let data = try await client.send( + BattleService.preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)), + as: PreVoteDataDTO.self + ) + + return data.toDomain() + } + + public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats { + let data = try await client.send( + BattleService.voteStats(battleId: battleId), + as: BattleVoteStatsDataDTO.self + ) + + return data.toDomain() + } + + public func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { + let data = try await client.send( + BattleService.postVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)), + as: PreVoteDataDTO.self + ) + + return data.toDomain() + } + + public func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionId: Int?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + let data = try await client.send( + BattleService.perspectives( + battleId: battleId, + query: PerspectivesQueryRequest(cursor: cursor, size: size, optionId: optionId, sort: sort?.queryValue) + ), + as: BattlePerspectivePageDataDTO.self + ) + + return data.toDomain() + } + + public func createPerspective( + battleId: Int, + content: String, + optionId: Int? + ) async throws -> BattlePerspective? { + _ = try await client.send( + BattleService.createPerspective( + battleId: battleId, + body: CreatePerspectiveRequest(content: content, optionId: optionId) + ), + as: CreatePerspectiveDataDTO.self + ) + + // 등록 응답에는 진영(option)이 없어 재조회로 채운다. 재조회가 실패해도 등록은 이미 성공했으므로 + // 실패로 뒤집지 않고 nil 을 돌려준다 — 호출부는 목록만 갱신하고 진영 전환을 건너뛴다. + let perspective = try? await fetchMyPerspective(battleId: battleId) + if perspective == nil { + PickeLogger.error("[BattleRepositoryImpl] createPerspective 재조회 실패 — 등록은 성공", category: .battle) + } + return perspective + } + + public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { + let dto: BattlePerspectiveDTO + do { + dto = try await client.send( + BattleService.myPerspective(battleId: battleId), + as: BattlePerspectiveDTO.self + ) + } catch { + PickeLogger.debug("[BattleRepositoryImpl] fetchMyPerspective failed (no participation): \(error.localizedDescription)", category: .battle) + return nil + } + + return dto.toDomain() + } + + public func fetchScenario(battleId: Int) async throws -> BattleScenario { + let data = try await client.send( + BattleService.scenario(battleId: battleId), + as: BattleScenarioDataDTO.self + ) + + return data.toDomain() + } + + public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { + let data = try await client.send( + BattleService.recommendations(battleId: battleId), + as: RecommendedBattlePageDataDTO.self + ) + + return data.toDomain() + } + + public func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal { + let data = try await client.send( + BattleService.createProposal(body: BattleProposalRequest( + category: draft.category, + topic: draft.topic, + positionA: draft.positionA, + positionB: draft.positionB, + description: draft.description + )), + as: BattleProposalDataDTO.self + ) + + return data.toDomain() + } +} diff --git a/Projects/Domain/Battle/Tests/BattleDomainTests.swift b/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift similarity index 100% rename from Projects/Domain/Battle/Tests/BattleDomainTests.swift rename to Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift diff --git a/Projects/Data/Battle/Tests/BattleRepositoryTests.swift b/Projects/Domain/BattleDomain/Tests/Sources/BattleRepositoryTests.swift similarity index 77% rename from Projects/Data/Battle/Tests/BattleRepositoryTests.swift rename to Projects/Domain/BattleDomain/Tests/Sources/BattleRepositoryTests.swift index 84fba6f0..5e2e6cd8 100644 --- a/Projects/Data/Battle/Tests/BattleRepositoryTests.swift +++ b/Projects/Domain/BattleDomain/Tests/Sources/BattleRepositoryTests.swift @@ -5,12 +5,13 @@ import Testing -@testable import BattleData +@testable import BattleDomain +import APIEndpoint import BattleDomainInterface -import CommonDomainInterface -import Entity import Foundation + +import Dependencies import HomeDomainInterface struct BattleRepositoryTests { @@ -41,7 +42,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let page = try await repo.fetchTodayBattles() @@ -56,7 +61,11 @@ struct BattleRepositoryTests { let json = """ { "statusCode": 200, "data": { "items": [], "totalCount": 0 }, "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let page = try await repo.fetchTodayBattles() @@ -64,21 +73,22 @@ struct BattleRepositoryTests { #expect(page.totalCount == 0) } - @Test func fetchTodayBattles_는_data_가_nil_이면_backendError_를_던진다() async throws { + @Test func fetchTodayBattles_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ { "statusCode": 400, "data": null, "error": {"code": "BAD_REQUEST", "message": "오늘의 배틀 조회 실패"} } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } - do { + await expectNetworkResponseError( + statusCode: 400, + code: "BAD_REQUEST", + message: "오늘의 배틀 조회 실패" + ) { _ = try await repo.fetchTodayBattles() - Issue.record("에러가 발생해야 한다") - } catch let error as BattleError { - guard case let .backendError(message) = error else { - Issue.record("backendError 여야 한다: \(error)") - return - } - #expect(message == "오늘의 배틀 조회 실패") } } @@ -111,7 +121,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let detail = try await repo.fetchBattle(battleId: 1) @@ -127,7 +141,11 @@ struct BattleRepositoryTests { let json = """ { "statusCode": 200, "data": {"voteId": 10, "status": "CREATED"}, "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let result = try await repo.submitPreVote(battleId: 1, optionId: 7) @@ -141,7 +159,11 @@ struct BattleRepositoryTests { let json = """ { "statusCode": 200, "data": {"voteId": 11, "status": "UPDATED"}, "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let result = try await repo.submitPostVote(battleId: 1, optionId: 3) @@ -166,7 +188,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let stats = try await repo.fetchVoteStats(battleId: 1) @@ -202,7 +228,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let page = try await repo.fetchPerspectives(battleId: 1, cursor: nil, size: nil, optionId: nil, sort: nil) @@ -216,7 +246,11 @@ struct BattleRepositoryTests { let json = """ { "statusCode": 200, "data": {"items": [], "nextCursor": null, "hasNext": false}, "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let page = try await repo.fetchPerspectives(battleId: 1, cursor: nil, size: nil, optionId: nil, sort: .popular) @@ -227,7 +261,7 @@ struct BattleRepositoryTests { // MARK: - createPerspective // createPerspective 는 생성 응답 확인 후 fetchMyPerspective 를 재호출한다. - // StubNetworkProvider 는 요청 target 과 무관하게 동일한 stubData 를 디코딩하므로, + // StubNetworkClient 는 요청 target 과 무관하게 동일한 stubData 를 디코딩하므로, // 이 fixture 는 CreatePerspectiveDataDTO(perspectiveId/status/createdAt) 와 // BattlePerspectiveDTO(perspectiveId/user/option/content/likeCount/commentCount/...) 양쪽 모두를 // 디코딩할 수 있도록 두 DTO 의 필드를 모두 포함한다. @@ -251,7 +285,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let perspective = try await repo.createPerspective(battleId: 1, content: "내 의견입니다", optionId: 1) @@ -274,28 +312,33 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let perspective = try await repo.createPerspective(battleId: 39, content: "ㄴㄴㄴ", optionId: 75) #expect(perspective == nil) } - @Test func createPerspective_는_data_가_nil_이면_backendError_를_던진다() async throws { + @Test func createPerspective_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ { "statusCode": 400, "data": null, "error": {"code": "BAD_REQUEST", "message": "댓글 작성 실패"} } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } - do { + await expectNetworkResponseError( + statusCode: 400, + code: "BAD_REQUEST", + message: "댓글 작성 실패" + ) { _ = try await repo.createPerspective(battleId: 1, content: "내용", optionId: nil) - Issue.record("에러가 발생해야 한다") - } catch let error as BattleError { - guard case let .backendError(message) = error else { - Issue.record("backendError 여야 한다: \(error)") - return - } - #expect(message == "댓글 작성 실패") } } @@ -319,7 +362,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let perspective = try await repo.fetchMyPerspective(battleId: 1) @@ -330,7 +377,11 @@ struct BattleRepositoryTests { let json = """ { "statusCode": 404, "data": null, "error": {"code": "NOT_FOUND", "message": "참여 이력 없음"} } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let perspective = try await repo.fetchMyPerspective(battleId: 1) @@ -338,7 +389,11 @@ struct BattleRepositoryTests { } @Test func fetchMyPerspective_는_provider_가_에러를_던지면_nil_을_반환한다() async throws { - let repo = BattleRepositoryImpl(provider: ThrowingStubNetworkProvider()) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + BattleRepositoryImpl() + } let perspective = try await repo.fetchMyPerspective(battleId: 1) @@ -373,7 +428,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let scenario = try await repo.fetchScenario(battleId: 1) @@ -403,7 +462,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let scenario = try await repo.fetchScenario(battleId: 1) @@ -436,7 +499,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let page = try await repo.fetchRecommendedBattles(battleId: 1) @@ -457,7 +524,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let page = try await repo.fetchRecommendedBattles(battleId: 1) @@ -488,7 +559,11 @@ struct BattleRepositoryTests { "error": null } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let draft = BattleProposalDraft( category: "철학", topic: "AI는 의식이 있는가", @@ -504,11 +579,15 @@ struct BattleRepositoryTests { #expect(proposal.createdAt != nil) } - @Test func proposeBattle_는_data_가_nil_이면_backendError_를_던진다() async throws { + @Test func proposeBattle_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ { "statusCode": 400, "data": null, "error": {"code": "BAD_REQUEST", "message": "배틀 제안 실패"} } """ - let repo = BattleRepositoryImpl(provider: StubNetworkProvider(stubData: Data(json.utf8))) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + BattleRepositoryImpl() + } let draft = BattleProposalDraft( category: "철학", topic: "주제", @@ -517,15 +596,12 @@ struct BattleRepositoryTests { description: "설명" ) - do { + await expectNetworkResponseError( + statusCode: 400, + code: "BAD_REQUEST", + message: "배틀 제안 실패" + ) { _ = try await repo.proposeBattle(draft) - Issue.record("에러가 발생해야 한다") - } catch let error as BattleError { - guard case let .backendError(message) = error else { - Issue.record("backendError 여야 한다: \(error)") - return - } - #expect(message == "배틀 제안 실패") } } } diff --git a/Projects/Data/Battle/Tests/BattleRequestMappingTests.swift b/Projects/Domain/BattleDomain/Tests/Sources/BattleRequestMappingTests.swift similarity index 87% rename from Projects/Data/Battle/Tests/BattleRequestMappingTests.swift rename to Projects/Domain/BattleDomain/Tests/Sources/BattleRequestMappingTests.swift index 443b50cd..984bca6d 100644 --- a/Projects/Data/Battle/Tests/BattleRequestMappingTests.swift +++ b/Projects/Domain/BattleDomain/Tests/Sources/BattleRequestMappingTests.swift @@ -5,10 +5,11 @@ import Testing -@testable import BattleData +@testable import BattleDomain +import APIEndpoint import Foundation -import NetworkHeader +@testable import PickeNetwork struct BattleRequestMappingTests { // MARK: - today @@ -188,10 +189,31 @@ struct BattleRequestMappingTests { // MARK: - headers - @Test func 모든_요청은_baseHeader_를_포함한다() throws { - let request = try BattleService.today.asURLRequest() - - #expect(request.value(forHTTPHeaderField: "Content-Type") != nil) - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer") == true) + @Test func 모든_요청은_자동_인증_정책을_사용한다() { + let services: [BattleService] = [ + .today, + .detail(battleId: 42), + .preVote(battleId: 42, body: PreVoteRequest(optionId: 1)), + .postVote(battleId: 42, body: PreVoteRequest(optionId: 1)), + .scenario(battleId: 42), + .voteStats(battleId: 42), + .perspectives(battleId: 42, query: PerspectivesQueryRequest()), + .createPerspective(battleId: 42, body: CreatePerspectiveRequest(content: "내용")), + .myPerspective(battleId: 42), + .recommendations(battleId: 42), + .createProposal( + body: BattleProposalRequest( + category: "철학", + topic: "AI", + positionA: "있다", + positionB: "없다", + description: "설명" + ) + ), + ] + + for service in services { + #expect(service.authorization == .automatic) + } } } diff --git a/Projects/Domain/BattleDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/BattleDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..ef74bae8 --- /dev/null +++ b/Projects/Domain/BattleDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,127 @@ +// +// TestSupport.swift +// BattleDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Comment/Testing/CommentDomainTesting.swift b/Projects/Domain/Comment/Testing/CommentDomainTesting.swift deleted file mode 100644 index e8e766ce..00000000 --- a/Projects/Domain/Comment/Testing/CommentDomainTesting.swift +++ /dev/null @@ -1,4 +0,0 @@ -import CommentDomainInterface - -/// Comment 도메인 목(mock) 배치용 네임스페이스. (현재 목 없음) -public enum CommentDomainTesting {} diff --git a/Projects/Domain/Comment/Interface/CommentInterface.swift b/Projects/Domain/CommentDomain/Interface/Sources/CommentInterface.swift similarity index 63% rename from Projects/Domain/Comment/Interface/CommentInterface.swift rename to Projects/Domain/CommentDomain/Interface/Sources/CommentInterface.swift index a19d8cec..25466934 100644 --- a/Projects/Domain/Comment/Interface/CommentInterface.swift +++ b/Projects/Domain/CommentDomain/Interface/Sources/CommentInterface.swift @@ -3,17 +3,16 @@ // DomainInterface // -import CommonDomainInterface import Dependencies import Foundation -import WeaveDI +import ComposableArchitecture public protocol CommentInterface: Sendable { func likeComment(commentId: Int) async throws -> CommentLikeResult func unlikeComment(commentId: Int) async throws -> CommentLikeResult } -public struct DefaultCommentRepositoryImpl: CommentInterface { +public struct MockCommentRepository: CommentInterface { public init() {} public func likeComment(commentId: Int) async throws -> CommentLikeResult { @@ -25,16 +24,12 @@ public struct DefaultCommentRepositoryImpl: CommentInterface { } } -public struct CommentRepositoryDependency: DependencyKey { - public static var liveValue: CommentInterface { - UnifiedDI.resolve(CommentInterface.self) ?? DefaultCommentRepositoryImpl() - } - - public static var testValue: CommentInterface { - UnifiedDI.resolve(CommentInterface.self) ?? DefaultCommentRepositoryImpl() - } +public enum CommentRepositoryDependency: TestDependencyKey { + public static var testValue: CommentInterface { MockCommentRepository() } +} - public static var previewValue: CommentInterface = liveValue +public enum CommentUseCaseDependency: TestDependencyKey { + public static var testValue: CommentInterface { MockCommentRepository() } } public extension DependencyValues { @@ -47,7 +42,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var commentUseCase: CommentInterface { - get { self[CommentRepositoryDependency.self] } - set { self[CommentRepositoryDependency.self] = newValue } + get { self[CommentUseCaseDependency.self] } + set { self[CommentUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/Common/Interface/Entity/Error/CommentError.swift b/Projects/Domain/CommentDomain/Interface/Sources/Error/CommentError.swift similarity index 97% rename from Projects/Domain/Common/Interface/Entity/Error/CommentError.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Error/CommentError.swift index d46c77b2..4b252d16 100644 --- a/Projects/Domain/Common/Interface/Entity/Error/CommentError.swift +++ b/Projects/Domain/CommentDomain/Interface/Sources/Error/CommentError.swift @@ -1,6 +1,6 @@ // // CommentError.swift -// Entity +// CommentDomainInterface // import Foundation diff --git a/Projects/Domain/Comment/Interface/Item/Comment.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/Comment.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/Comment.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/Comment.swift diff --git a/Projects/Domain/Comment/Interface/Item/CommentAuthor.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentAuthor.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/CommentAuthor.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentAuthor.swift diff --git a/Projects/Domain/Comment/Interface/Item/CommentFilter.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentFilter.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/CommentFilter.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentFilter.swift diff --git a/Projects/Domain/Comment/Interface/Item/CommentItem.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentItem.swift similarity index 98% rename from Projects/Domain/Comment/Interface/Item/CommentItem.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentItem.swift index 0d9e9fac..ddb3c245 100644 --- a/Projects/Domain/Comment/Interface/Item/CommentItem.swift +++ b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentItem.swift @@ -3,7 +3,7 @@ // Entity // -import CommonDomainInterface +import BattleDomainInterface import Foundation public struct CommentItem: Equatable, Identifiable { diff --git a/Projects/Domain/Common/Interface/Entity/Comment/CommentLikeResult.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentLikeResult.swift similarity index 93% rename from Projects/Domain/Common/Interface/Entity/Comment/CommentLikeResult.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentLikeResult.swift index 902bd085..e251ebea 100644 --- a/Projects/Domain/Common/Interface/Entity/Comment/CommentLikeResult.swift +++ b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentLikeResult.swift @@ -1,6 +1,6 @@ // // CommentLikeResult.swift -// Entity +// CommentDomainInterface // import Foundation diff --git a/Projects/Domain/Comment/Interface/Item/CommentOption.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentOption.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/CommentOption.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentOption.swift diff --git a/Projects/Domain/Comment/Interface/Item/CommentReplyItem.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentReplyItem.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/CommentReplyItem.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentReplyItem.swift diff --git a/Projects/Domain/Comment/Interface/Item/CommentSort.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentSort.swift similarity index 93% rename from Projects/Domain/Comment/Interface/Item/CommentSort.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentSort.swift index cc991f5e..a4f647b5 100644 --- a/Projects/Domain/Comment/Interface/Item/CommentSort.swift +++ b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentSort.swift @@ -3,7 +3,7 @@ // Entity // -import CommonDomainInterface +import BattleDomainInterface import Foundation public enum CommentSort: String, CaseIterable, Equatable { diff --git a/Projects/Domain/Comment/Interface/Item/CommentSortType.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentSortType.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/CommentSortType.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentSortType.swift diff --git a/Projects/Domain/Comment/Interface/Item/CommentTab.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentTab.swift similarity index 100% rename from Projects/Domain/Comment/Interface/Item/CommentTab.swift rename to Projects/Domain/CommentDomain/Interface/Sources/Item/CommentTab.swift diff --git a/Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveComment.swift b/Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveComment.swift similarity index 100% rename from Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveComment.swift rename to Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveComment.swift diff --git a/Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveCommentMutationResult.swift b/Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveCommentMutationResult.swift similarity index 100% rename from Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveCommentMutationResult.swift rename to Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveCommentMutationResult.swift diff --git a/Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveCommentPage.swift b/Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveCommentPage.swift similarity index 100% rename from Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveCommentPage.swift rename to Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveCommentPage.swift diff --git a/Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveCommentUser.swift b/Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveCommentUser.swift similarity index 100% rename from Projects/Domain/Comment/Interface/PerspectiveComment/PerspectiveCommentUser.swift rename to Projects/Domain/CommentDomain/Interface/Sources/PerspectiveComment/PerspectiveCommentUser.swift diff --git a/Projects/Domain/Comment/Project.swift b/Projects/Domain/CommentDomain/Project.swift similarity index 57% rename from Projects/Domain/Comment/Project.swift rename to Projects/Domain/CommentDomain/Project.swift index 3b0f53b3..c31a8468 100644 --- a/Projects/Domain/Comment/Project.swift +++ b/Projects/Domain/CommentDomain/Project.swift @@ -1,21 +1,26 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .microModule(name: "CommentDomain"), +import ProjectDescription + +let project = Project.makeModule( + name: "CommentDomain", bundleId: .appBundleID(name: ".CommentDomain"), + product: .framework, settings: .settings(), dependencies: [ - .Domain(.Common, .interface), - .SPM.weaveDI, + .serviceAssembly, + .domain(.battle, .interface), .SPM.composableArchitecture, ], + hasTests: true, + hasInterface: true, interfaceDependencies: [ - .Domain(.Common, .interface), - .SPM.weaveDI, + .domain(.battle, .interface), .SPM.composableArchitecture, - ] -) + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Domain/CommentDomain/Sources/CommentLiveDependencies.swift b/Projects/Domain/CommentDomain/Sources/CommentLiveDependencies.swift new file mode 100644 index 00000000..e2ba8015 --- /dev/null +++ b/Projects/Domain/CommentDomain/Sources/CommentLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// CommentLiveDependencies.swift +// CommentDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import CommentDomainInterface +import ComposableArchitecture + +extension CommentUseCaseDependency: DependencyKey { + public static var liveValue: CommentInterface { CommentUseCaseImpl() } +} + +extension CommentRepositoryDependency: DependencyKey { + public static var liveValue: CommentInterface { CommentRepositoryImpl() } +} diff --git a/Projects/Domain/Comment/Sources/CommentUseCase.swift b/Projects/Domain/CommentDomain/Sources/CommentUseCase.swift similarity index 95% rename from Projects/Domain/Comment/Sources/CommentUseCase.swift rename to Projects/Domain/CommentDomain/Sources/CommentUseCase.swift index 4654d861..74e52a68 100644 --- a/Projects/Domain/Comment/Sources/CommentUseCase.swift +++ b/Projects/Domain/CommentDomain/Sources/CommentUseCase.swift @@ -6,7 +6,6 @@ import Foundation import CommentDomainInterface -import CommonDomainInterface import ComposableArchitecture diff --git a/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift b/Projects/Domain/CommentDomain/Sources/Model/DTO/CommentLikeDataDTO.swift similarity index 72% rename from Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift rename to Projects/Domain/CommentDomain/Sources/Model/DTO/CommentLikeDataDTO.swift index f84c4b0d..b96cc2a8 100644 --- a/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift +++ b/Projects/Domain/CommentDomain/Sources/Model/DTO/CommentLikeDataDTO.swift @@ -1,11 +1,11 @@ // // CommentLikeDataDTO.swift -// Model +// CommentDomain // +import PickeNetworkInterface import Foundation -import Model public struct CommentLikeDataDTO: Decodable { public let perspectiveId: Int @@ -13,5 +13,3 @@ public struct CommentLikeDataDTO: Decodable { /// perspective 좋아요 응답에는 isLiked 가 없어 옵셔널. public let isLiked: Bool? } - -public typealias CommentLikeResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift b/Projects/Domain/CommentDomain/Sources/Model/Mapper/CommentLikeDataDTO+.swift similarity index 84% rename from Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift rename to Projects/Domain/CommentDomain/Sources/Model/Mapper/CommentLikeDataDTO+.swift index 3c30e56f..ce54cc16 100644 --- a/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift +++ b/Projects/Domain/CommentDomain/Sources/Model/Mapper/CommentLikeDataDTO+.swift @@ -1,9 +1,9 @@ // // CommentLikeDataDTO+.swift -// Model +// CommentDomain // -import CommonDomainInterface +import CommentDomainInterface import Foundation public extension CommentLikeDataDTO { diff --git a/Projects/Domain/CommentDomain/Sources/Repository/CommentRepositoryImpl.swift b/Projects/Domain/CommentDomain/Sources/Repository/CommentRepositoryImpl.swift new file mode 100644 index 00000000..afb46517 --- /dev/null +++ b/Projects/Domain/CommentDomain/Sources/Repository/CommentRepositoryImpl.swift @@ -0,0 +1,37 @@ +// +// CommentRepositoryImpl.swift +// Repository +// + +import Foundation + +import Dependencies + +import APIEndpoint +import CommentDomainInterface +import PickeNetwork + + +public final class CommentRepositoryImpl: CommentInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + let data = try await client.send( + CommentService.like(commentId: commentId), + as: CommentLikeDataDTO.self + ) + + return data.toDomain() + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + let data = try await client.send( + CommentService.unlike(commentId: commentId), + as: CommentLikeDataDTO.self + ) + + return data.toDomain() + } +} diff --git a/Projects/Domain/Comment/Tests/CommentDomainTests.swift b/Projects/Domain/CommentDomain/Tests/Sources/CommentDomainTests.swift similarity index 100% rename from Projects/Domain/Comment/Tests/CommentDomainTests.swift rename to Projects/Domain/CommentDomain/Tests/Sources/CommentDomainTests.swift diff --git a/Projects/Data/Comment/Tests/CommentRepositoryTests.swift b/Projects/Domain/CommentDomain/Tests/Sources/CommentRepositoryTests.swift similarity index 59% rename from Projects/Data/Comment/Tests/CommentRepositoryTests.swift rename to Projects/Domain/CommentDomain/Tests/Sources/CommentRepositoryTests.swift index 361723af..afd73c80 100644 --- a/Projects/Data/Comment/Tests/CommentRepositoryTests.swift +++ b/Projects/Domain/CommentDomain/Tests/Sources/CommentRepositoryTests.swift @@ -4,12 +4,14 @@ // import Foundation + +import Dependencies import Testing -@testable import CommentData +@testable import CommentDomain +import APIEndpoint import CommentDomainInterface -import CommonDomainInterface struct CommentRepositoryTests { // MARK: - likeComment @@ -27,9 +29,11 @@ struct CommentRepositoryTests { "error": null } """ - let repo = CommentRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + CommentRepositoryImpl() + } let result = try await repo.likeComment(commentId: 10) @@ -49,9 +53,11 @@ struct CommentRepositoryTests { "error": null } """ - let repo = CommentRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + CommentRepositoryImpl() + } let result = try await repo.likeComment(commentId: 10) @@ -59,7 +65,7 @@ struct CommentRepositoryTests { } @Test - func likeComment_emptyData_throwsBackendErrorWithMessage() async throws { + func likeComment_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -67,20 +73,28 @@ struct CommentRepositoryTests { "error": { "code": "COMMENT_400", "message": "이미 좋아요한 댓글입니다" } } """ - let repo = CommentRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + CommentRepositoryImpl() + } - await #expect(throws: CommentError.backendError("이미 좋아요한 댓글입니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "COMMENT_400", + message: "이미 좋아요한 댓글입니다" + ) { try await repo.likeComment(commentId: 10) } } @Test func likeComment_networkFailure_throws() async throws { - let repo = CommentRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + CommentRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.likeComment(commentId: 10) @@ -102,9 +116,11 @@ struct CommentRepositoryTests { "error": null } """ - let repo = CommentRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + CommentRepositoryImpl() + } let result = try await repo.unlikeComment(commentId: 10) @@ -112,7 +128,7 @@ struct CommentRepositoryTests { } @Test - func unlikeComment_emptyData_throwsBackendErrorWithDefaultMessage() async throws { + func unlikeComment_emptyData_throwsNetworkDataMissing() async throws { let json = """ { "statusCode": 500, @@ -120,20 +136,24 @@ struct CommentRepositoryTests { "error": null } """ - let repo = CommentRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + CommentRepositoryImpl() + } - await #expect(throws: CommentError.backendError("댓글 좋아요 취소 응답이 비어 있습니다")) { + await expectNetworkDataMissing { try await repo.unlikeComment(commentId: 10) } } @Test func unlikeComment_networkFailure_throws() async throws { - let repo = CommentRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + CommentRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.unlikeComment(commentId: 10) diff --git a/Projects/Data/Comment/Tests/CommentRequestMappingTests.swift b/Projects/Domain/CommentDomain/Tests/Sources/CommentRequestMappingTests.swift similarity index 59% rename from Projects/Data/Comment/Tests/CommentRequestMappingTests.swift rename to Projects/Domain/CommentDomain/Tests/Sources/CommentRequestMappingTests.swift index ca89e394..1419dc01 100644 --- a/Projects/Data/Comment/Tests/CommentRequestMappingTests.swift +++ b/Projects/Domain/CommentDomain/Tests/Sources/CommentRequestMappingTests.swift @@ -6,25 +6,27 @@ import Foundation import Testing -@testable import CommentData +@testable import CommentDomain -import NetworkHeader +import API +import APIEndpoint +@testable import PickeNetwork struct CommentRequestMappingTests { @Test - func like_urlPath_matchesCommentAPIDescription() { + func like_path_matchesCommentAPIDescription() { let service = CommentService.like(commentId: 123) - #expect(service.urlPath == CommentAPI.like(commentId: 123).description) - #expect(service.urlPath == "123/likes") + #expect(service.path == CommentAPI.like(commentId: 123).description) + #expect(service.path == "123/likes") } @Test - func unlike_urlPath_matchesCommentAPIDescription() { + func unlike_path_matchesCommentAPIDescription() { let service = CommentService.unlike(commentId: 123) - #expect(service.urlPath == CommentAPI.unlike(commentId: 123).description) - #expect(service.urlPath == "123/likes") + #expect(service.path == CommentAPI.unlike(commentId: 123).description) + #expect(service.path == "123/likes") } @Test @@ -54,11 +56,8 @@ struct CommentRequestMappingTests { } @Test - func headers_includeBaseHeaderFields() throws { - let service = CommentService.like(commentId: 123) - let request = try service.asURLRequest() - - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true) + func allCases_useAutomaticAuthorizationPolicy() { + #expect(CommentService.like(commentId: 123).authorization == .automatic) + #expect(CommentService.unlike(commentId: 123).authorization == .automatic) } } diff --git a/Projects/Domain/CommentDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/CommentDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..617f1c3d --- /dev/null +++ b/Projects/Domain/CommentDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,141 @@ +// +// TestSupport.swift +// CommentDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} + +func expectNetworkDataMissing(operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("dataMissing 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case .decoding(.dataMissing) = error else { + Issue.record("dataMissing 에러여야 합니다: \(error)") + return + } + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Common/Project.swift b/Projects/Domain/Common/Project.swift deleted file mode 100644 index 7784a52f..00000000 --- a/Projects/Domain/Common/Project.swift +++ /dev/null @@ -1,14 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .microModule(name: "CommonDomain"), - bundleId: .appBundleID(name: ".CommonDomain"), - product: .staticFramework, - settings: .settings(), - dependencies: [], - interfaceDependencies: [] -) diff --git a/Projects/Domain/Common/Sources/Exported/CommonDomainExported.swift b/Projects/Domain/Common/Sources/Exported/CommonDomainExported.swift deleted file mode 100644 index 00a4c158..00000000 --- a/Projects/Domain/Common/Sources/Exported/CommonDomainExported.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// CommonDomainExported.swift -// CommonDomain -// - -@_exported import CommonDomainInterface diff --git a/Projects/Domain/Common/Testing/CommonDomainTesting.swift b/Projects/Domain/Common/Testing/CommonDomainTesting.swift deleted file mode 100644 index 7ced7a24..00000000 --- a/Projects/Domain/Common/Testing/CommonDomainTesting.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// CommonDomainTesting.swift -// CommonDomainTesting -// - -import CommonDomainInterface - -public enum CommonDomainTesting {} diff --git a/Projects/Domain/Common/Tests/CommonDomainTests.swift b/Projects/Domain/Common/Tests/CommonDomainTests.swift deleted file mode 100644 index 990e951f..00000000 --- a/Projects/Domain/Common/Tests/CommonDomainTests.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// CommonDomainTests.swift -// CommonDomainTests -// - -@testable import CommonDomain -import Testing - -struct CommonDomainTests { - @Test - func commonDomainExample() { - #expect(true) - } -} diff --git a/Projects/Domain/Domain/Project.swift b/Projects/Domain/Domain/Project.swift deleted file mode 100644 index ddf3899a..00000000 --- a/Projects/Domain/Domain/Project.swift +++ /dev/null @@ -1,37 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Domain"), - bundleId: .appBundleID(name: ".Domain"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Common), - .Domain(implements: .Entity), - .Domain(implements: .DomainInterface), - .Domain(implements: .UseCase), - .Domain(.Attendance, .interface), - .Domain(.Attendance), - .Domain(.Auth, .interface), - .Domain(.Auth), - .Domain(.Battle, .interface), - .Domain(.Battle), - .Domain(.Search, .interface), - .Domain(.Search), - .Domain(.Comment, .interface), - .Domain(.Comment), - .Domain(.Home, .interface), - .Domain(.Home), - .Domain(.Notification, .interface), - .Domain(.Notification), - .Domain(.Perspective, .interface), - .Domain(.Perspective), - .Domain(.Profile), - ], - sources: ["Sources/**"] -) diff --git a/Projects/Domain/DomainAssembly/Project.swift b/Projects/Domain/DomainAssembly/Project.swift new file mode 100644 index 00000000..d525ca0b --- /dev/null +++ b/Projects/Domain/DomainAssembly/Project.swift @@ -0,0 +1,27 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "DomainAssembly", + bundleId: .appBundleID(name: ".DomainAssembly"), + product: .framework, + settings: .settings(), + dependencies: [ + .domain(.appUpdate, .implementation), + .domain(.attendance, .implementation), + .domain(.auth, .implementation), + .domain(.battle, .implementation), + .domain(.search, .implementation), + .domain(.comment, .implementation), + .domain(.home, .implementation), + .domain(.notification, .implementation), + .domain(.perspective, .implementation), + .domain(.profile, .implementation), + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Domain/Domain/Sources/Exported/DomainExported.swift b/Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift similarity index 70% rename from Projects/Domain/Domain/Sources/Exported/DomainExported.swift rename to Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift index e0a5a7af..0aba6fc3 100644 --- a/Projects/Domain/Domain/Sources/Exported/DomainExported.swift +++ b/Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift @@ -1,20 +1,20 @@ // -// DomainExported.swift -// Domain +// DomainAssemblyExported.swift +// DomainAssembly // // MARK: - Domain 레이어 한번에 노출 +@_exported import AppUpdateDomain +@_exported import AppUpdateDomainInterface +@_exported import AttendanceDomain +@_exported import AttendanceDomainInterface @_exported import AuthDomain @_exported import AuthDomainInterface @_exported import BattleDomain @_exported import BattleDomainInterface @_exported import CommentDomain @_exported import CommentDomainInterface -@_exported import CommonDomain -@_exported import CommonDomainInterface -@_exported import DomainInterface -@_exported import Entity @_exported import HomeDomain @_exported import HomeDomainInterface @_exported import NotificationDomain @@ -22,6 +22,6 @@ @_exported import PerspectiveDomain @_exported import PerspectiveDomainInterface @_exported import ProfileDomain +@_exported import ProfileDomainInterface @_exported import SearchDomain @_exported import SearchDomainInterface -@_exported import UseCase diff --git a/Projects/Domain/DomainAssembly/Sources/SessionCacheInvalidator+Live.swift b/Projects/Domain/DomainAssembly/Sources/SessionCacheInvalidator+Live.swift new file mode 100644 index 00000000..853f3f55 --- /dev/null +++ b/Projects/Domain/DomainAssembly/Sources/SessionCacheInvalidator+Live.swift @@ -0,0 +1,16 @@ +// +// SessionCacheInvalidator+Live.swift +// DomainAssembly +// + +import PickeStorageInterface + +import Dependencies + +// 로컬 캐시(LocalDataSource)를 두는 도메인이 생기면 여기서 clear 를 엮는다. +// 지금은 비울 대상이 없어 계약만 살아 있고 실제 동작은 없다. +extension SessionCacheInvalidatorDependency: DependencyKey { + public static var liveValue: any SessionCacheInvalidating { + NoopSessionCacheInvalidator() + } +} diff --git a/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift b/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift new file mode 100644 index 00000000..c92d8e7a --- /dev/null +++ b/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift @@ -0,0 +1,31 @@ +// +// DomainAssemblyExportTests.swift +// DomainAssemblyTests +// + +import Testing + +// 엄브렐러가 제 역할을 하는지 보는 테스트라 개별 도메인 모듈은 일부러 import 하지 않는다. +import DomainAssembly + +struct DomainAssemblyExportTests { + /// 재노출이 하나라도 빠지면 이 파일이 컴파일되지 않는다. + /// 화면 코드가 `import DomainAssembly` 하나로 도메인 전체를 쓰는 전제를 지킨다. + @Test + func 모든_도메인_인터페이스가_엄브렐러로_보인다() { + let interfaces: [Any.Type] = [ + (any AppUpdateUseCaseInterface).self, + (any AttendanceInterface).self, + (any AuthUseCaseInterface).self, + (any BattleInterface).self, + (any CommentInterface).self, + (any HomeInterface).self, + (any NotificationInterface).self, + (any PerspectiveInterface).self, + (any ProfileInterface).self, + (any SearchInterface).self, + ] + + #expect(interfaces.count == 10) + } +} diff --git a/Projects/Domain/DomainInterface/DomainInterfaceTests/Sources/Test.swift b/Projects/Domain/DomainInterface/DomainInterfaceTests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Domain/DomainInterface/DomainInterfaceTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Domain/DomainInterface/Project.swift b/Projects/Domain/DomainInterface/Project.swift deleted file mode 100644 index 819263af..00000000 --- a/Projects/Domain/DomainInterface/Project.swift +++ /dev/null @@ -1,22 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "DomainInterface"), - bundleId: .appBundleID(name: ".DomainInterface"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .Domain(.Home, .interface), - .Domain(implements: .Entity), - .SPM.weaveDI, - .SPM.composableArchitecture, - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Domain/DomainInterface/Sources/AppUpdate/Default/DefaultAppUpdateUseCaseImpl.swift b/Projects/Domain/DomainInterface/Sources/AppUpdate/Default/DefaultAppUpdateUseCaseImpl.swift deleted file mode 100644 index 1fb05952..00000000 --- a/Projects/Domain/DomainInterface/Sources/AppUpdate/Default/DefaultAppUpdateUseCaseImpl.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// DefaultAppUpdateUseCaseImpl.swift -// DomainInterface -// - -import Foundation - -import Entity - -public struct DefaultAppUpdateUseCaseImpl: AppUpdateUseCaseInterface { - public init() {} - - public func checkForUpdate() async throws -> AppUpdateInfo? { - nil - } -} diff --git a/Projects/Domain/DomainInterface/Sources/AppUpdate/Interface/AppUpdateInterface.swift b/Projects/Domain/DomainInterface/Sources/AppUpdate/Interface/AppUpdateInterface.swift deleted file mode 100644 index ad7279c3..00000000 --- a/Projects/Domain/DomainInterface/Sources/AppUpdate/Interface/AppUpdateInterface.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// AppUpdateInterface.swift -// DomainInterface -// - -import Foundation - -import Entity -import WeaveDI - -public protocol AppUpdateInterface: Sendable { - func checkForUpdate() async throws -> AppUpdateInfo -} - -public struct AppUpdateRepositoryDependency: DependencyKey { - public static var liveValue: AppUpdateInterface { - UnifiedDI.resolve(AppUpdateInterface.self) ?? DefaultAppUpdateRepositoryImpl() - } - - public static var testValue: AppUpdateInterface { - UnifiedDI.resolve(AppUpdateInterface.self) ?? DefaultAppUpdateRepositoryImpl() - } - - public static var previewValue: AppUpdateInterface = liveValue -} - -public extension DependencyValues { - var appUpdateRepository: AppUpdateInterface { - get { self[AppUpdateRepositoryDependency.self] } - set { self[AppUpdateRepositoryDependency.self] = newValue } - } -} diff --git a/Projects/Domain/DomainInterface/Sources/AppUpdate/Interface/AppUpdateUseCaseInterface.swift b/Projects/Domain/DomainInterface/Sources/AppUpdate/Interface/AppUpdateUseCaseInterface.swift deleted file mode 100644 index 9c234a75..00000000 --- a/Projects/Domain/DomainInterface/Sources/AppUpdate/Interface/AppUpdateUseCaseInterface.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// AppUpdateUseCaseInterface.swift -// DomainInterface -// - -import Foundation - -import Entity -import WeaveDI - -public protocol AppUpdateUseCaseInterface: Sendable { - func checkForUpdate() async throws -> AppUpdateInfo? -} - -public struct AppUpdateUseCaseDependency: DependencyKey { - public static var liveValue: AppUpdateUseCaseInterface { - UnifiedDI.resolve(AppUpdateUseCaseInterface.self) ?? DefaultAppUpdateUseCaseImpl() - } - - public static var testValue: AppUpdateUseCaseInterface { - UnifiedDI.resolve(AppUpdateUseCaseInterface.self) ?? DefaultAppUpdateUseCaseImpl() - } - - public static var previewValue: AppUpdateUseCaseInterface = liveValue -} - -public extension DependencyValues { - var appUpdateUseCase: AppUpdateUseCaseInterface { - get { self[AppUpdateUseCaseDependency.self] } - set { self[AppUpdateUseCaseDependency.self] = newValue } - } -} diff --git a/Projects/Domain/DomainInterface/Sources/Manager/AuthLocalStorage.swift b/Projects/Domain/DomainInterface/Sources/Manager/AuthLocalStorage.swift deleted file mode 100644 index 414dc9c0..00000000 --- a/Projects/Domain/DomainInterface/Sources/Manager/AuthLocalStorage.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// AuthLocalStorage.swift -// DomainInterface -// - -import Foundation - -public enum AuthLocalStorage { - private static let authCodeKey = "picke.auth.authCode" - private static let idTokenKey = "picke.auth.idToken" - - public static var authCode: String? { - get { UserDefaults.standard.string(forKey: authCodeKey) } - set { UserDefaults.standard.set(newValue, forKey: authCodeKey) } - } - - public static var idToken: String? { - get { UserDefaults.standard.string(forKey: idTokenKey) } - set { UserDefaults.standard.set(newValue, forKey: idTokenKey) } - } - - public static func clear() { - UserDefaults.standard.removeObject(forKey: authCodeKey) - UserDefaults.standard.removeObject(forKey: idTokenKey) - } -} diff --git a/Projects/Domain/DomainInterface/Sources/Manager/Default/DefaultMemoryKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manager/Default/DefaultMemoryKeychainManager.swift deleted file mode 100644 index 982444ae..00000000 --- a/Projects/Domain/DomainInterface/Sources/Manager/Default/DefaultMemoryKeychainManager.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// DefaultMemoryKeychainManager.swift -// DomainInterface -// -// Created by Wonji Suh on 1/2/26. -// - -import Foundation - -public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendable { - private var accessTokenStorage: String? - private var refreshTokenStorage: String? - - public init() {} - - public func save( - accessToken: String, - refreshToken: String - ) { - accessTokenStorage = accessToken - refreshTokenStorage = refreshToken - } - - public func saveAccessToken(_ token: String) { - accessTokenStorage = token - } - - public func clearAccessToken() { - accessTokenStorage = nil - } - - public func saveRefreshToken(_ token: String) { - refreshTokenStorage = token - } - - public func accessToken() -> String? { - accessTokenStorage - } - - public func refreshToken() -> String? { - refreshTokenStorage - } - - public func clear() { - accessTokenStorage = nil - refreshTokenStorage = nil - } -} diff --git a/Projects/Domain/DomainInterface/Sources/Manager/Interface/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manager/Interface/KeychainManagerInterface.swift deleted file mode 100644 index 753630dc..00000000 --- a/Projects/Domain/DomainInterface/Sources/Manager/Interface/KeychainManagerInterface.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// KeychainManagerInterface.swift -// DomainInterface -// -// Created by Wonji Suh on 1/2/26. -// - -import Foundation -import WeaveDI - -public protocol KeychainManaging: Sendable { - func save( - accessToken: String, - refreshToken: String - ) - func saveAccessToken(_ token: String) - func clearAccessToken() - func saveRefreshToken(_ token: String) - func accessToken() -> String? - func refreshToken() -> String? - func clear() -} - -public struct KeychainManagerDependency: DependencyKey { - public static var liveValue: KeychainManaging { - return UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() - } - - public static var testValue: KeychainManaging { - return InMemoryKeychainManager() - } - - public static var previewValue: KeychainManaging = testValue -} - -public extension DependencyValues { - var keychainManager: KeychainManaging { - get { self[KeychainManagerDependency.self] } - set { self[KeychainManagerDependency.self] = newValue } - } -} diff --git a/Projects/Domain/DomainTesting/Sources/Analytics/NoopAnalyticsUseCase.swift b/Projects/Domain/DomainTesting/Sources/Analytics/NoopAnalyticsUseCase.swift deleted file mode 100644 index 34883143..00000000 --- a/Projects/Domain/DomainTesting/Sources/Analytics/NoopAnalyticsUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// NoopAnalyticsUseCase.swift -// DomainTesting -// - -import UseCase - -public extension AnalyticsUseCase { - static let noop = AnalyticsUseCase( - registerBaseProperties: {}, - identify: { _, _ in }, - track: { _ in }, - reset: {} - ) -} diff --git a/Projects/Domain/DomainTesting/Sources/AppUpdate/MockAppUpdateUseCase.swift b/Projects/Domain/DomainTesting/Sources/AppUpdate/MockAppUpdateUseCase.swift deleted file mode 100644 index 65f65b3d..00000000 --- a/Projects/Domain/DomainTesting/Sources/AppUpdate/MockAppUpdateUseCase.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// MockAppUpdateUseCase.swift -// DomainTesting -// - -import DomainInterface -import Entity -import UseCase - -public struct MockAppUpdateUseCase: AppUpdateUseCaseInterface { - public var info: AppUpdateInfo? - - public init(info: AppUpdateInfo? = nil) { - self.info = info - } - - public func checkForUpdate() async throws -> AppUpdateInfo? { - info - } -} diff --git a/Projects/Domain/DomainTesting/Sources/Manager/MockKeychainManager.swift b/Projects/Domain/DomainTesting/Sources/Manager/MockKeychainManager.swift deleted file mode 100644 index 9e9c5e2d..00000000 --- a/Projects/Domain/DomainTesting/Sources/Manager/MockKeychainManager.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// MockKeychainManager.swift -// DomainInterface -// -// Created by TDD Automation on 2026-04-16 -// - -import Foundation -import WeaveDI - -import DomainInterface - -public final class MockKeychainManager: KeychainManaging, @unchecked Sendable { - // MARK: - Configuration - - public enum Configuration { - case success - case failure - case saveFailure - case getFailure - } - - // MARK: - State - - private var configuration: Configuration = .success - private var saveCallCount = 0 - private var getCallCount = 0 - private var clearCallCount = 0 - private var storedAccessToken: String? - private var storedRefreshToken: String? - - // MARK: - Public Configuration Methods - - public init(configuration: Configuration = .success) { - self.configuration = configuration - } - - public static func success() -> MockKeychainManager { - MockKeychainManager(configuration: .success) - } - - public static func failure() -> MockKeychainManager { - MockKeychainManager(configuration: .failure) - } - - public static func saveFailure() -> MockKeychainManager { - MockKeychainManager(configuration: .saveFailure) - } - - public static func getFailure() -> MockKeychainManager { - MockKeychainManager(configuration: .getFailure) - } - - // MARK: - Call Count Getters - - public func getSaveCallCount() -> Int { saveCallCount } - public func getGetCallCount() -> Int { getCallCount } - public func getClearCallCount() -> Int { clearCallCount } - public func getStoredAccessToken() -> String? { storedAccessToken } - public func getStoredRefreshToken() -> String? { storedRefreshToken } - - // MARK: - KeychainManaging Implementation - - public func save( - accessToken: String, - refreshToken: String - ) { - saveCallCount += 1 - - switch configuration { - case .success: - storedAccessToken = accessToken - storedRefreshToken = refreshToken - case .saveFailure, .failure: - // 저장 실패 시뮬레이션 (실제로는 저장하지 않음) - break - default: - storedAccessToken = accessToken - storedRefreshToken = refreshToken - } - } - - public func saveAccessToken(_ token: String) { - saveCallCount += 1 - if case .success = configuration { - storedAccessToken = token - } - } - - public func clearAccessToken() { - storedAccessToken = nil - } - - public func saveRefreshToken(_ token: String) { - saveCallCount += 1 - if case .success = configuration { - storedRefreshToken = token - } - } - - public func accessToken() -> String? { - getCallCount += 1 - - switch configuration { - case .success: - return storedAccessToken ?? "mock-stored-access-token" - case .getFailure, .failure: - return nil - default: - return storedAccessToken - } - } - - public func refreshToken() -> String? { - getCallCount += 1 - - switch configuration { - case .success: - return storedRefreshToken ?? "mock-stored-refresh-token" - case .getFailure, .failure: - return nil - default: - return storedRefreshToken - } - } - - public func clear() { - clearCallCount += 1 - - switch configuration { - case .success: - storedAccessToken = nil - storedRefreshToken = nil - case .failure: - // 클리어 실패 시뮬레이션 (실제로는 클리어하지 않음) - break - default: - storedAccessToken = nil - storedRefreshToken = nil - } - } - - public func reset() { - configuration = .success - saveCallCount = 0 - getCallCount = 0 - clearCallCount = 0 - storedAccessToken = nil - storedRefreshToken = nil - } -} diff --git a/Projects/Domain/Entity/EntityTests/Sources/Test.swift b/Projects/Domain/Entity/EntityTests/Sources/Test.swift deleted file mode 100644 index 88831a39..00000000 --- a/Projects/Domain/Entity/EntityTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Domain/Entity/Project.swift b/Projects/Domain/Entity/Project.swift deleted file mode 100644 index 69f3ee1a..00000000 --- a/Projects/Domain/Entity/Project.swift +++ /dev/null @@ -1,19 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Entity"), - bundleId: .appBundleID(name: ".Entity"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Home, .interface), - .Domain(.Profile, .interface), - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Domain/Entity/Sources/Exported/ProfileDomainBridge.swift b/Projects/Domain/Entity/Sources/Exported/ProfileDomainBridge.swift deleted file mode 100644 index 9dd0c3ce..00000000 --- a/Projects/Domain/Entity/Sources/Exported/ProfileDomainBridge.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// ProfileDomainBridge.swift -// Entity -// - -@_exported import ProfileDomainInterface diff --git a/Projects/Domain/Home/Interface/Core/HomeBundle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift similarity index 100% rename from Projects/Domain/Home/Interface/Core/HomeBundle.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift diff --git a/Projects/Domain/Home/Interface/Explore/ExploreCategory.swift b/Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreCategory.swift similarity index 100% rename from Projects/Domain/Home/Interface/Explore/ExploreCategory.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreCategory.swift diff --git a/Projects/Domain/Home/Interface/Explore/ExploreItem.swift b/Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreItem.swift similarity index 100% rename from Projects/Domain/Home/Interface/Explore/ExploreItem.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreItem.swift diff --git a/Projects/Domain/Home/Interface/Explore/ExploreItemPage.swift b/Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreItemPage.swift similarity index 100% rename from Projects/Domain/Home/Interface/Explore/ExploreItemPage.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreItemPage.swift diff --git a/Projects/Domain/Home/Interface/Explore/ExploreSort.swift b/Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreSort.swift similarity index 100% rename from Projects/Domain/Home/Interface/Explore/ExploreSort.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Explore/ExploreSort.swift diff --git a/Projects/Domain/Home/Interface/Repository/HomeInterface.swift b/Projects/Domain/HomeDomain/Interface/Sources/Repository/HomeInterface.swift similarity index 58% rename from Projects/Domain/Home/Interface/Repository/HomeInterface.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Repository/HomeInterface.swift index 0cd36937..0c922a0f 100644 --- a/Projects/Domain/Home/Interface/Repository/HomeInterface.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Repository/HomeInterface.swift @@ -6,7 +6,7 @@ // import Foundation -import WeaveDI +import ComposableArchitecture /// 홈 화면 데이터 조회 Repository 인터페이스. public protocol HomeInterface: Sendable { @@ -15,16 +15,12 @@ public protocol HomeInterface: Sendable { // MARK: - Dependency -public struct HomeRepositoryDependency: DependencyKey { - public static var liveValue: HomeInterface { - return UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() - } - - public static var testValue: HomeInterface { - return UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() - } +public enum HomeRepositoryDependency: TestDependencyKey { + public static var testValue: HomeInterface { MockHomeRepository() } +} - public static var previewValue: HomeInterface = liveValue +public enum HomeUseCaseDependency: TestDependencyKey { + public static var testValue: HomeInterface { MockHomeRepository() } } public extension DependencyValues { @@ -37,7 +33,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var homeUseCase: HomeInterface { - get { self[HomeRepositoryDependency.self] } - set { self[HomeRepositoryDependency.self] = newValue } + get { self[HomeUseCaseDependency.self] } + set { self[HomeUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/Home/Interface/Repository/DefaultHomeRepositoryImpl.swift b/Projects/Domain/HomeDomain/Interface/Sources/Repository/MockHomeRepository.swift similarity index 69% rename from Projects/Domain/Home/Interface/Repository/DefaultHomeRepositoryImpl.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Repository/MockHomeRepository.swift index d966240e..31213666 100644 --- a/Projects/Domain/Home/Interface/Repository/DefaultHomeRepositoryImpl.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Repository/MockHomeRepository.swift @@ -1,5 +1,5 @@ // -// DefaultHomeRepositoryImpl.swift +// MockHomeRepository.swift // DomainInterface // // Created by Wonji Suh on 5/16/26. @@ -8,7 +8,7 @@ import Foundation /// Home Repository 기본 구현체 — 미주입 환경에서 mock 번들을 반환한다. -public final class DefaultHomeRepositoryImpl: HomeInterface, @unchecked Sendable { +public final class MockHomeRepository: HomeInterface, @unchecked Sendable { public init() {} public func fetchHome() async throws -> HomeBundle { diff --git a/Projects/Domain/Home/Interface/Scenario/BattleScenario+.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/BattleScenario+.swift similarity index 74% rename from Projects/Domain/Home/Interface/Scenario/BattleScenario+.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/BattleScenario+.swift index 4e5667c7..99c69d82 100644 --- a/Projects/Domain/Home/Interface/Scenario/BattleScenario+.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/BattleScenario+.swift @@ -13,9 +13,6 @@ public extension BattleScenario { } /// 노드 종료 시간(초). - /// 다음 노드(autoNext 또는 인터랙티브 분기)의 시작 시각이 실제 오디오 경계이므로 우선 사용한다. - /// (audioDuration 합산은 실제 오디오와 어긋나, 선택지가 마지막 대사 도중에 떠 음성이 끊기는 문제가 있음) - /// 다음 노드가 없으면(클로징) 노드 시작 + audioDuration. func nodeEndTime(for node: ScenarioNode) -> TimeInterval { let nextNodeIds: [Int] = { if let auto = node.autoNextNodeId { return [auto] } diff --git a/Projects/Domain/Home/Interface/Scenario/BattleScenario.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/BattleScenario.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/BattleScenario.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/BattleScenario.swift diff --git a/Projects/Domain/Home/Interface/Scenario/Chat/ChatMessage.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatMessage.swift similarity index 83% rename from Projects/Domain/Home/Interface/Scenario/Chat/ChatMessage.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatMessage.swift index 7bfa6e95..1202828f 100644 --- a/Projects/Domain/Home/Interface/Scenario/Chat/ChatMessage.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatMessage.swift @@ -10,8 +10,6 @@ public struct ChatMessage: Equatable, Identifiable, Hashable { public let speaker: ChatSpeaker public let text: String /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 - /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 - /// 경우엔 nil. public let startTimeMs: Int? public var id: UUID { messageId } diff --git a/Projects/Domain/Home/Interface/Scenario/Chat/ChatRoomBundle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatRoomBundle.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/Chat/ChatRoomBundle.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatRoomBundle.swift diff --git a/Projects/Domain/Home/Interface/Scenario/Chat/ChatSpeaker.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatSpeaker.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/Chat/ChatSpeaker.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatSpeaker.swift diff --git a/Projects/Domain/Home/Interface/Scenario/Chat/ChatSpeakerSide.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatSpeakerSide.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/Chat/ChatSpeakerSide.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/Chat/ChatSpeakerSide.swift diff --git a/Projects/Domain/Home/Interface/Scenario/RecommendedPathKey.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/RecommendedPathKey.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift diff --git a/Projects/Domain/Home/Interface/Scenario/ScenarioInteractiveOption.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/ScenarioInteractiveOption.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift diff --git a/Projects/Domain/Home/Interface/Scenario/ScenarioNode.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioNode.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/ScenarioNode.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioNode.swift diff --git a/Projects/Domain/Home/Interface/Scenario/ScenarioPhilosopher.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioPhilosopher.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/ScenarioPhilosopher.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioPhilosopher.swift diff --git a/Projects/Domain/Home/Interface/Scenario/ScenarioScript.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioScript.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/ScenarioScript.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioScript.swift diff --git a/Projects/Domain/Home/Interface/Scenario/ScenarioSpeakerType.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioSpeakerType.swift similarity index 100% rename from Projects/Domain/Home/Interface/Scenario/ScenarioSpeakerType.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioSpeakerType.swift diff --git a/Projects/Domain/Home/Interface/Section/Battle/BestBattle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/BestBattle.swift similarity index 98% rename from Projects/Domain/Home/Interface/Section/Battle/BestBattle.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/BestBattle.swift index 4bea9f94..48a3122b 100644 --- a/Projects/Domain/Home/Interface/Section/Battle/BestBattle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/BestBattle.swift @@ -6,7 +6,6 @@ // import Foundation -import CommonDomainInterface /// "Best 배틀" 랭킹 카드 — API 의 bestBattles. public struct BestBattle: Equatable, Identifiable { diff --git a/Projects/Domain/Home/Interface/Section/Battle/HeroBattle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HeroBattle.swift similarity index 98% rename from Projects/Domain/Home/Interface/Section/Battle/HeroBattle.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HeroBattle.swift index 0dfc0061..bcef747b 100644 --- a/Projects/Domain/Home/Interface/Section/Battle/HeroBattle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HeroBattle.swift @@ -6,7 +6,6 @@ // import Foundation -import CommonDomainInterface /// 홈 화면 최상단 "EDITOR PICK" 카드 — API 의 editorPicks. public struct HeroBattle: Equatable, Identifiable { diff --git a/Projects/Domain/Home/Interface/Section/Battle/HotBattle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift similarity index 98% rename from Projects/Domain/Home/Interface/Section/Battle/HotBattle.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift index 2bb58373..42c50770 100644 --- a/Projects/Domain/Home/Interface/Section/Battle/HotBattle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift @@ -6,7 +6,6 @@ // import Foundation -import CommonDomainInterface /// "지금 뜨는 배틀" 가로 스크롤 카드 — API 의 trendingBattles. public struct HotBattle: Equatable, Identifiable { diff --git a/Projects/Domain/Home/Interface/Section/Battle/NewBattle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift similarity index 99% rename from Projects/Domain/Home/Interface/Section/Battle/NewBattle.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift index aa76a8f1..0830e3d1 100644 --- a/Projects/Domain/Home/Interface/Section/Battle/NewBattle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift @@ -6,7 +6,6 @@ // import Foundation -import CommonDomainInterface /// "새로운 배틀" 리스트 아이템 — API 의 newBattles. public struct NewBattle: Equatable, Identifiable { diff --git a/Projects/Domain/Home/Interface/Section/QuizQuestion.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/QuizQuestion.swift similarity index 100% rename from Projects/Domain/Home/Interface/Section/QuizQuestion.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/QuizQuestion.swift diff --git a/Projects/Domain/Home/Interface/Section/Vote/VoteOption.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Vote/VoteOption.swift similarity index 100% rename from Projects/Domain/Home/Interface/Section/Vote/VoteOption.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/Vote/VoteOption.swift diff --git a/Projects/Domain/Home/Interface/Section/Vote/VoteQuestion.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Vote/VoteQuestion.swift similarity index 100% rename from Projects/Domain/Home/Interface/Section/Vote/VoteQuestion.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Section/Vote/VoteQuestion.swift diff --git a/Projects/Domain/Common/Interface/Entity/Battle/BattleTag.swift b/Projects/Domain/HomeDomain/Interface/Sources/Tag/BattleTag.swift similarity index 95% rename from Projects/Domain/Common/Interface/Entity/Battle/BattleTag.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Tag/BattleTag.swift index 76ca9704..73dbd356 100644 --- a/Projects/Domain/Common/Interface/Entity/Battle/BattleTag.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Tag/BattleTag.swift @@ -1,6 +1,6 @@ // // BattleTag.swift -// CommonDomain +// HomeDomainInterface // // Created by Wonji Suh on 5/16/26. // diff --git a/Projects/Domain/Common/Interface/Entity/Battle/TagType.swift b/Projects/Domain/HomeDomain/Interface/Sources/Tag/TagType.swift similarity index 96% rename from Projects/Domain/Common/Interface/Entity/Battle/TagType.swift rename to Projects/Domain/HomeDomain/Interface/Sources/Tag/TagType.swift index e1f8bf46..e0c79b3a 100644 --- a/Projects/Domain/Common/Interface/Entity/Battle/TagType.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Tag/TagType.swift @@ -1,6 +1,6 @@ // // TagType.swift -// CommonDomain +// HomeDomainInterface // import Foundation diff --git a/Projects/Domain/Home/Project.swift b/Projects/Domain/HomeDomain/Project.swift similarity index 52% rename from Projects/Domain/Home/Project.swift rename to Projects/Domain/HomeDomain/Project.swift index 17c02485..5113d658 100644 --- a/Projects/Domain/Home/Project.swift +++ b/Projects/Domain/HomeDomain/Project.swift @@ -1,22 +1,24 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .microModule(name: "HomeDomain"), +import ProjectDescription + +let project = Project.makeModule( + name: "HomeDomain", bundleId: .appBundleID(name: ".HomeDomain"), + product: .framework, settings: .settings(), dependencies: [ - .Domain(.Common, .interface), - .Domain(.Notification), - .SPM.weaveDI, - .SPM.composableArchitecture, + .serviceAssembly, + .domain(.auth, .interface), ], + hasTests: true, + hasInterface: true, interfaceDependencies: [ - .Domain(.Common, .interface), - .SPM.weaveDI, .SPM.composableArchitecture, - ] -) + ], + hasTesting: true +) \ No newline at end of file diff --git a/Projects/Domain/HomeDomain/Sources/HomeLiveDependencies.swift b/Projects/Domain/HomeDomain/Sources/HomeLiveDependencies.swift new file mode 100644 index 00000000..589f6987 --- /dev/null +++ b/Projects/Domain/HomeDomain/Sources/HomeLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// HomeLiveDependencies.swift +// HomeDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import HomeDomainInterface +import ComposableArchitecture + +extension HomeUseCaseDependency: DependencyKey { + public static var liveValue: HomeInterface { HomeUseCaseImpl() } +} + +extension HomeRepositoryDependency: DependencyKey { + public static var liveValue: HomeInterface { HomeRepositoryImpl() } +} diff --git a/Projects/Domain/Home/Sources/HomeUseCase.swift b/Projects/Domain/HomeDomain/Sources/HomeUseCase.swift similarity index 100% rename from Projects/Domain/Home/Sources/HomeUseCase.swift rename to Projects/Domain/HomeDomain/Sources/HomeUseCase.swift diff --git a/Projects/Data/Home/Sources/Model/HomeDataDTO+.swift b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift similarity index 98% rename from Projects/Data/Home/Sources/Model/HomeDataDTO+.swift rename to Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift index 999f477d..03de9c6b 100644 --- a/Projects/Data/Home/Sources/Model/HomeDataDTO+.swift +++ b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift @@ -1,11 +1,10 @@ // // HomeDataDTO+.swift -// Model +// HomeDomain // // Created by Wonji Suh on 5/16/26. // -import CommonDomainInterface import Foundation import HomeDomainInterface diff --git a/Projects/Data/Home/Sources/Model/HomeDataDTO.swift b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift similarity index 97% rename from Projects/Data/Home/Sources/Model/HomeDataDTO.swift rename to Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift index 07754ac5..d5bb8dfa 100644 --- a/Projects/Data/Home/Sources/Model/HomeDataDTO.swift +++ b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift @@ -1,13 +1,13 @@ // // HomeDataDTO.swift -// Model +// HomeDomain // // Created by Wonji Suh on 5/16/26. // +import PickeNetworkInterface import Foundation -import Model /// `GET /api/v1/home` 의 `data` 필드 페이로드. public struct HomeDataDTO: Decodable { @@ -112,4 +112,3 @@ public struct NewBattleDTO: Decodable, Identifiable { } /// `GET /api/v1/home` 응답 타입 별칭. -public typealias HomeResponseDTO = BaseResponseDTO diff --git a/Projects/Domain/HomeDomain/Sources/Repository/HomeRepositoryImpl.swift b/Projects/Domain/HomeDomain/Sources/Repository/HomeRepositoryImpl.swift new file mode 100644 index 00000000..36407856 --- /dev/null +++ b/Projects/Domain/HomeDomain/Sources/Repository/HomeRepositoryImpl.swift @@ -0,0 +1,31 @@ +// +// HomeRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +import Dependencies + +import APIEndpoint +import HomeDomainInterface +import PickeNetwork + +import AuthDomainInterface + +public final class HomeRepositoryImpl: HomeInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func fetchHome() async throws -> HomeBundle { + let data = try await client.send( + HomeService.home, + as: HomeDataDTO.self + ) + + return data.toDomain() + } +} diff --git a/Projects/Domain/Home/Testing/MockHomeRepository.swift b/Projects/Domain/HomeDomain/Testing/MockHomeRepository.swift similarity index 100% rename from Projects/Domain/Home/Testing/MockHomeRepository.swift rename to Projects/Domain/HomeDomain/Testing/MockHomeRepository.swift diff --git a/Projects/Domain/Home/Tests/HomeDomainTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/HomeDomainTests.swift similarity index 100% rename from Projects/Domain/Home/Tests/HomeDomainTests.swift rename to Projects/Domain/HomeDomain/Tests/Sources/HomeDomainTests.swift diff --git a/Projects/Data/Home/Tests/HomeRepositoryTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift similarity index 86% rename from Projects/Data/Home/Tests/HomeRepositoryTests.swift rename to Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift index e3a002da..458c8eae 100644 --- a/Projects/Data/Home/Tests/HomeRepositoryTests.swift +++ b/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift @@ -4,12 +4,14 @@ // import Foundation + +import Dependencies import Testing -@testable import HomeData +@testable import HomeDomain -import CommonDomainInterface -import Entity +import APIEndpoint +import AuthDomainInterface import HomeDomainInterface struct HomeRepositoryTests { @@ -143,7 +145,11 @@ struct HomeRepositoryTests { @Test func fetchHome_성공시_도메인_번들로_매핑된다() async throws { let data = try #require(Self.fullEnvelope.data(using: .utf8)) - let repository = HomeRepositoryImpl(provider: StubNetworkProvider(stubData: data)) + let repository = withDependencies { + $0.networkClient = StubNetworkClient(stubData: data) + } operation: { + HomeRepositoryImpl() + } let bundle = try await repository.fetchHome() @@ -185,7 +191,11 @@ struct HomeRepositoryTests { @Test func fetchHome_빈_섹션이면_빈_배열로_매핑된다() async throws { let data = try #require(Self.emptyEnvelope.data(using: .utf8)) - let repository = HomeRepositoryImpl(provider: StubNetworkProvider(stubData: data)) + let repository = withDependencies { + $0.networkClient = StubNetworkClient(stubData: data) + } operation: { + HomeRepositoryImpl() + } let bundle = try await repository.fetchHome() @@ -198,17 +208,29 @@ struct HomeRepositoryTests { #expect(bundle.newBattles.isEmpty) } - @Test func fetchHome_data가_비어있으면_backendError_를_던진다() async throws { + @Test func fetchHome_error_봉투이면_네트워크_response_에러를_던진다() async throws { let data = try #require(Self.emptyDataEnvelope.data(using: .utf8)) - let repository = HomeRepositoryImpl(provider: StubNetworkProvider(stubData: data)) + let repository = withDependencies { + $0.networkClient = StubNetworkClient(stubData: data) + } operation: { + HomeRepositoryImpl() + } - await #expect(throws: AuthError.backendError("홈 데이터를 불러오지 못했습니다")) { + await expectNetworkResponseError( + statusCode: 500, + code: "HOME_500", + message: "홈 데이터를 불러오지 못했습니다" + ) { _ = try await repository.fetchHome() } } @Test func fetchHome_네트워크_에러_시_에러를_전파한다() async throws { - let repository = HomeRepositoryImpl(provider: ThrowingStubNetworkProvider()) + let repository = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + HomeRepositoryImpl() + } await #expect(throws: (any Error).self) { _ = try await repository.fetchHome() diff --git a/Projects/Data/Home/Tests/HomeRequestMappingTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/HomeRequestMappingTests.swift similarity index 62% rename from Projects/Data/Home/Tests/HomeRequestMappingTests.swift rename to Projects/Domain/HomeDomain/Tests/Sources/HomeRequestMappingTests.swift index d6988648..b05e83b6 100644 --- a/Projects/Data/Home/Tests/HomeRequestMappingTests.swift +++ b/Projects/Domain/HomeDomain/Tests/Sources/HomeRequestMappingTests.swift @@ -6,9 +6,10 @@ import Foundation import Testing -@testable import HomeData +@testable import HomeDomain -import NetworkHeader +import APIEndpoint +@testable import PickeNetwork struct HomeRequestMappingTests { @Test func home_요청은_GET_이며_경로가_api_v1_home_이다() throws { @@ -25,10 +26,7 @@ struct HomeRequestMappingTests { #expect(request.httpBody == nil) } - @Test func home_요청은_baseHeader_를_포함한다() throws { - let request = try HomeService.home.asURLRequest() - - #expect(request.value(forHTTPHeaderField: "Content-Type") != nil) - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer") == true) + @Test func home_요청은_자동_인증_정책을_사용한다() { + #expect(HomeService.home.authorization == .automatic) } } diff --git a/Projects/Domain/HomeDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/HomeDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..a0f89eb2 --- /dev/null +++ b/Projects/Domain/HomeDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,127 @@ +// +// TestSupport.swift +// HomeDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Notification/Testing/NotificationDomainTesting.swift b/Projects/Domain/Notification/Testing/NotificationDomainTesting.swift deleted file mode 100644 index 011dce33..00000000 --- a/Projects/Domain/Notification/Testing/NotificationDomainTesting.swift +++ /dev/null @@ -1,4 +0,0 @@ -import NotificationDomainInterface - -/// Notification 도메인 목(mock) 배치용 네임스페이스. (현재 목 없음) -public enum NotificationDomainTesting {} diff --git a/Projects/Domain/Notification/Interface/DefaultNotificationRepositoryImpl.swift b/Projects/Domain/NotificationDomain/Interface/Sources/MockNotificationRepository.swift similarity index 87% rename from Projects/Domain/Notification/Interface/DefaultNotificationRepositoryImpl.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/MockNotificationRepository.swift index 9846494f..e6cb72e8 100644 --- a/Projects/Domain/Notification/Interface/DefaultNotificationRepositoryImpl.swift +++ b/Projects/Domain/NotificationDomain/Interface/Sources/MockNotificationRepository.swift @@ -1,11 +1,11 @@ // -// DefaultNotificationRepositoryImpl.swift +// MockNotificationRepository.swift // DomainInterface // import Foundation -public struct DefaultNotificationRepositoryImpl: NotificationInterface { +public struct MockNotificationRepository: NotificationInterface { public init() {} public func fetchNotifications( diff --git a/Projects/Domain/Notification/Interface/NotificationCategory.swift b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationCategory.swift similarity index 100% rename from Projects/Domain/Notification/Interface/NotificationCategory.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/NotificationCategory.swift diff --git a/Projects/Domain/Notification/Interface/NotificationDetail.swift b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationDetail.swift similarity index 100% rename from Projects/Domain/Notification/Interface/NotificationDetail.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/NotificationDetail.swift diff --git a/Projects/Domain/Notification/Interface/NotificationError.swift b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationError.swift similarity index 100% rename from Projects/Domain/Notification/Interface/NotificationError.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/NotificationError.swift diff --git a/Projects/Domain/Notification/Interface/NotificationInterface.swift b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationInterface.swift similarity index 66% rename from Projects/Domain/Notification/Interface/NotificationInterface.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/NotificationInterface.swift index b42f4565..e1fe315c 100644 --- a/Projects/Domain/Notification/Interface/NotificationInterface.swift +++ b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationInterface.swift @@ -4,7 +4,7 @@ // import Foundation -import WeaveDI +import ComposableArchitecture public protocol NotificationInterface: Sendable { func fetchNotifications( @@ -20,16 +20,12 @@ public protocol NotificationInterface: Sendable { func markAllAsRead() async throws -> Bool } -public struct NotificationRepositoryDependency: DependencyKey { - public static var liveValue: NotificationInterface { - UnifiedDI.resolve(NotificationInterface.self) ?? DefaultNotificationRepositoryImpl() - } - - public static var testValue: NotificationInterface { - UnifiedDI.resolve(NotificationInterface.self) ?? DefaultNotificationRepositoryImpl() - } +public enum NotificationRepositoryDependency: TestDependencyKey { + public static var testValue: NotificationInterface { MockNotificationRepository() } +} - public static var previewValue: NotificationInterface = liveValue +public enum NotificationUseCaseDependency: TestDependencyKey { + public static var testValue: NotificationInterface { MockNotificationRepository() } } public extension DependencyValues { @@ -42,7 +38,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var notificationUseCase: NotificationInterface { - get { self[NotificationRepositoryDependency.self] } - set { self[NotificationRepositoryDependency.self] = newValue } + get { self[NotificationUseCaseDependency.self] } + set { self[NotificationUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/Notification/Interface/NotificationItem.swift b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationItem.swift similarity index 100% rename from Projects/Domain/Notification/Interface/NotificationItem.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/NotificationItem.swift diff --git a/Projects/Domain/Notification/Interface/NotificationPage.swift b/Projects/Domain/NotificationDomain/Interface/Sources/NotificationPage.swift similarity index 100% rename from Projects/Domain/Notification/Interface/NotificationPage.swift rename to Projects/Domain/NotificationDomain/Interface/Sources/NotificationPage.swift diff --git a/Projects/Domain/Notification/Project.swift b/Projects/Domain/NotificationDomain/Project.swift similarity index 62% rename from Projects/Domain/Notification/Project.swift rename to Projects/Domain/NotificationDomain/Project.swift index 97cd5235..6013e872 100644 --- a/Projects/Domain/Notification/Project.swift +++ b/Projects/Domain/NotificationDomain/Project.swift @@ -1,19 +1,23 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .microModule(name: "NotificationDomain"), +import ProjectDescription + +let project = Project.makeModule( + name: "NotificationDomain", bundleId: .appBundleID(name: ".NotificationDomain"), + product: .framework, settings: .settings(), dependencies: [ - .SPM.weaveDI, - .SPM.composableArchitecture, + .serviceAssembly, ], + hasTests: true, + hasInterface: true, interfaceDependencies: [ - .SPM.weaveDI, .SPM.composableArchitecture, - ] -) + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Data/Notification/Sources/Model/DTO/NotificationDataDTO.swift b/Projects/Domain/NotificationDomain/Sources/Model/DTO/NotificationDataDTO.swift similarity index 81% rename from Projects/Data/Notification/Sources/Model/DTO/NotificationDataDTO.swift rename to Projects/Domain/NotificationDomain/Sources/Model/DTO/NotificationDataDTO.swift index 8446376c..14e3cbd0 100644 --- a/Projects/Data/Notification/Sources/Model/DTO/NotificationDataDTO.swift +++ b/Projects/Domain/NotificationDomain/Sources/Model/DTO/NotificationDataDTO.swift @@ -1,11 +1,11 @@ // // NotificationDataDTO.swift -// Model +// NotificationDomain // +import PickeNetworkInterface import Foundation -import Model public struct NotificationDataDTO: Decodable { public let items: [NotificationItemDTO]? @@ -45,6 +45,4 @@ public struct NotificationUnreadDTO: Decodable { public let hasUnread: Bool? } -public typealias NotificationResponseDTO = BaseResponseDTO -public typealias NotificationDetailResponseDTO = BaseResponseDTO -public typealias NotificationUnreadResponseDTO = BaseResponseDTO + diff --git a/Projects/Data/Notification/Sources/Model/Mapper/NotificationDataDTO+.swift b/Projects/Domain/NotificationDomain/Sources/Model/Mapper/NotificationDataDTO+.swift similarity index 98% rename from Projects/Data/Notification/Sources/Model/Mapper/NotificationDataDTO+.swift rename to Projects/Domain/NotificationDomain/Sources/Model/Mapper/NotificationDataDTO+.swift index 7cf4b39c..dc1afc18 100644 --- a/Projects/Data/Notification/Sources/Model/Mapper/NotificationDataDTO+.swift +++ b/Projects/Domain/NotificationDomain/Sources/Model/Mapper/NotificationDataDTO+.swift @@ -1,6 +1,6 @@ // // NotificationDataDTO+.swift -// Model +// NotificationDomain // import Foundation diff --git a/Projects/Domain/NotificationDomain/Sources/NotificationLiveDependencies.swift b/Projects/Domain/NotificationDomain/Sources/NotificationLiveDependencies.swift new file mode 100644 index 00000000..8aa419fd --- /dev/null +++ b/Projects/Domain/NotificationDomain/Sources/NotificationLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// NotificationLiveDependencies.swift +// NotificationDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import NotificationDomainInterface +import ComposableArchitecture + +extension NotificationUseCaseDependency: DependencyKey { + public static var liveValue: NotificationInterface { NotificationUseCaseImpl() } +} + +extension NotificationRepositoryDependency: DependencyKey { + public static var liveValue: NotificationInterface { NotificationRepositoryImpl() } +} diff --git a/Projects/Domain/Notification/Sources/NotificationUseCase.swift b/Projects/Domain/NotificationDomain/Sources/NotificationUseCase.swift similarity index 100% rename from Projects/Domain/Notification/Sources/NotificationUseCase.swift rename to Projects/Domain/NotificationDomain/Sources/NotificationUseCase.swift diff --git a/Projects/Domain/NotificationDomain/Sources/Repository/NotificationRepositoryImpl.swift b/Projects/Domain/NotificationDomain/Sources/Repository/NotificationRepositoryImpl.swift new file mode 100644 index 00000000..fbcf3cb8 --- /dev/null +++ b/Projects/Domain/NotificationDomain/Sources/Repository/NotificationRepositoryImpl.swift @@ -0,0 +1,65 @@ +// +// NotificationRepositoryImpl.swift +// Repository +// + +import Foundation + +import Dependencies + +import APIEndpoint +import PickeNetwork +import NotificationDomainInterface + + +public final class NotificationRepositoryImpl: NotificationInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage { + let data = try await client.send( + NotificationService.list( query: NotificationsQueryRequest( category: category.rawValue, page: page, size: size ) ), + as: NotificationDataDTO.self + ) + + return data.toDomain() + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + let data = try await client.send( + NotificationService.detail(notificationId: notificationId), + as: NotificationDetailDTO.self + ) + + return data.toDomain() + } + + public func hasUnreadNotifications() async throws -> Bool { + let data = try await client.send( + NotificationService.unread, + as: NotificationUnreadDTO.self + ) + return data.hasUnread ?? false + } + + public func markAsRead(notificationId: Int) async throws { + _ = try await client.send( + NotificationService.read(notificationId: notificationId), + as: PickeEmptyResponse.self + ) + } + + /// PATCH /read-all — 서버가 처리 후 최신 미읽음 여부(`data.hasUnread`)를 반환한다. 그 값을 그대로 쓴다. + public func markAllAsRead() async throws -> Bool { + let data = try await client.send( + NotificationService.readAll, + as: NotificationUnreadDTO.self + ) + return data.hasUnread ?? false + } +} diff --git a/Projects/Domain/Notification/Tests/NotificationDomainTests.swift b/Projects/Domain/NotificationDomain/Tests/Sources/NotificationDomainTests.swift similarity index 100% rename from Projects/Domain/Notification/Tests/NotificationDomainTests.swift rename to Projects/Domain/NotificationDomain/Tests/Sources/NotificationDomainTests.swift diff --git a/Projects/Data/Notification/Tests/NotificationRepositoryTests.swift b/Projects/Domain/NotificationDomain/Tests/Sources/NotificationRepositoryTests.swift similarity index 62% rename from Projects/Data/Notification/Tests/NotificationRepositoryTests.swift rename to Projects/Domain/NotificationDomain/Tests/Sources/NotificationRepositoryTests.swift index 1a3c1fe5..53c9a155 100644 --- a/Projects/Data/Notification/Tests/NotificationRepositoryTests.swift +++ b/Projects/Domain/NotificationDomain/Tests/Sources/NotificationRepositoryTests.swift @@ -4,10 +4,13 @@ // import Foundation + +import Dependencies import Testing -@testable import NotificationData +@testable import NotificationDomain +import APIEndpoint import NotificationDomainInterface struct NotificationRepositoryTests { @@ -35,9 +38,11 @@ struct NotificationRepositoryTests { "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } let page = try await repo.fetchNotifications(category: .all, page: 0, size: 20) @@ -66,9 +71,11 @@ struct NotificationRepositoryTests { "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } let page = try await repo.fetchNotifications(category: .all, page: 0, size: 20) @@ -77,7 +84,7 @@ struct NotificationRepositoryTests { } @Test - func fetchNotifications_withNullData_throwsBackendError() async throws { + func fetchNotifications_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 200, @@ -85,11 +92,17 @@ struct NotificationRepositoryTests { "error": { "code": "NOT_FOUND", "message": "알림이 없습니다" } } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } - await #expect(throws: NotificationError.backendError("알림이 없습니다")) { + await expectNetworkResponseError( + statusCode: 200, + code: "NOT_FOUND", + message: "알림이 없습니다" + ) { try await repo.fetchNotifications(category: .all, page: 0, size: 20) } } @@ -114,9 +127,11 @@ struct NotificationRepositoryTests { "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } let detail = try await repo.fetchNotificationDetail(notificationId: 5) @@ -132,7 +147,7 @@ struct NotificationRepositoryTests { } @Test - func fetchNotificationDetail_withNullData_throwsBackendError() async throws { + func fetchNotificationDetail_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 200, @@ -140,11 +155,17 @@ struct NotificationRepositoryTests { "error": { "code": "NOT_FOUND", "message": "알림 상세가 없습니다" } } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } - await #expect(throws: NotificationError.backendError("알림 상세가 없습니다")) { + await expectNetworkResponseError( + statusCode: 200, + code: "NOT_FOUND", + message: "알림 상세가 없습니다" + ) { try await repo.fetchNotificationDetail(notificationId: 5) } } @@ -154,9 +175,11 @@ struct NotificationRepositoryTests { let json = """ { "statusCode": 200, "data": { "hasUnread": true }, "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } let hasUnread = try await repo.hasUnreadNotifications() @@ -168,9 +191,11 @@ struct NotificationRepositoryTests { let json = """ { "statusCode": 200, "data": { "hasUnread": false }, "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } let hasUnread = try await repo.hasUnreadNotifications() @@ -178,17 +203,19 @@ struct NotificationRepositoryTests { } @Test - func hasUnreadNotifications_withNullData_defaultsToFalse() async throws { + func hasUnreadNotifications_withNullData_throwsNetworkDataMissing() async throws { let json = """ { "statusCode": 200, "data": null, "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) - - let hasUnread = try await repo.hasUnreadNotifications() + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } - #expect(hasUnread == false) + await expectNetworkDataMissing { + _ = try await repo.hasUnreadNotifications() + } } @Test @@ -196,22 +223,28 @@ struct NotificationRepositoryTests { let json = """ { "statusCode": 200, "data": "OK", "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } try await repo.markAsRead(notificationId: 1) } @Test - func markAllAsRead_doesNotThrow_onSuccessResponse() async throws { + func markAllAsRead_returnsUnreadState_onSuccessResponse() async throws { let json = """ - { "statusCode": 200, "data": "OK", "error": null } + { "statusCode": 200, "data": { "hasUnread": false }, "error": null } """ - let repo = NotificationRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + NotificationRepositoryImpl() + } - try await repo.markAllAsRead() + let hasUnread = try await repo.markAllAsRead() + + #expect(hasUnread == false) } } diff --git a/Projects/Data/Notification/Tests/NotificationRequestMappingTests.swift b/Projects/Domain/NotificationDomain/Tests/Sources/NotificationRequestMappingTests.swift similarity index 77% rename from Projects/Data/Notification/Tests/NotificationRequestMappingTests.swift rename to Projects/Domain/NotificationDomain/Tests/Sources/NotificationRequestMappingTests.swift index 70c9bd7b..72e0ced4 100644 --- a/Projects/Data/Notification/Tests/NotificationRequestMappingTests.swift +++ b/Projects/Domain/NotificationDomain/Tests/Sources/NotificationRequestMappingTests.swift @@ -6,10 +6,11 @@ import Foundation import Testing -@testable import NotificationData +@testable import NotificationDomain import API -import NetworkHeader +import APIEndpoint +@testable import PickeNetwork struct NotificationRequestMappingTests { @Test @@ -19,7 +20,7 @@ struct NotificationRequestMappingTests { ) let request = try service.asURLRequest() - #expect(service.urlPath == NotificationAPI.list.description) + #expect(service.path == NotificationAPI.list.description) #expect(service.method == .get) #expect(request.url?.path == "/api/v1/notifications") #expect(request.httpBody == nil) @@ -35,7 +36,7 @@ struct NotificationRequestMappingTests { let service = NotificationService.list(query: NotificationsQueryRequest()) let request = try service.asURLRequest() - #expect(service.parameters == nil) + #expect(service.parameters != nil) #expect(request.url?.query == nil) } @@ -44,7 +45,7 @@ struct NotificationRequestMappingTests { let service = NotificationService.unread let request = try service.asURLRequest() - #expect(service.urlPath == NotificationAPI.unread.description) + #expect(service.path == NotificationAPI.unread.description) #expect(service.method == .get) #expect(request.url?.path == "/api/v1/notifications/unread") #expect(service.parameters == nil) @@ -56,7 +57,7 @@ struct NotificationRequestMappingTests { let service = NotificationService.detail(notificationId: 42) let request = try service.asURLRequest() - #expect(service.urlPath == NotificationAPI.detail(notificationId: 42).description) + #expect(service.path == NotificationAPI.detail(notificationId: 42).description) #expect(service.method == .get) #expect(request.url?.path == "/api/v1/notifications/42") #expect(service.parameters == nil) @@ -68,7 +69,7 @@ struct NotificationRequestMappingTests { let service = NotificationService.read(notificationId: 42) let request = try service.asURLRequest() - #expect(service.urlPath == NotificationAPI.read(notificationId: 42).description) + #expect(service.path == NotificationAPI.read(notificationId: 42).description) #expect(service.method == .patch) #expect(request.url?.path == "/api/v1/notifications/42/read") #expect(service.parameters == nil) @@ -80,7 +81,7 @@ struct NotificationRequestMappingTests { let service = NotificationService.readAll let request = try service.asURLRequest() - #expect(service.urlPath == NotificationAPI.readAll.description) + #expect(service.path == NotificationAPI.readAll.description) #expect(service.method == .patch) #expect(request.url?.path == "/api/v1/notifications/read-all") #expect(service.parameters == nil) @@ -88,7 +89,7 @@ struct NotificationRequestMappingTests { } @Test - func allCases_includeBaseHeaderFields() throws { + func allCases_useAutomaticAuthorizationPolicy() { let services: [NotificationService] = [ .list(query: NotificationsQueryRequest()), .unread, @@ -98,9 +99,7 @@ struct NotificationRequestMappingTests { ] for service in services { - let request = try service.asURLRequest() - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true) + #expect(service.authorization == .automatic) } } } diff --git a/Projects/Domain/NotificationDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/NotificationDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..73a85923 --- /dev/null +++ b/Projects/Domain/NotificationDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,141 @@ +// +// TestSupport.swift +// NotificationDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} + +func expectNetworkDataMissing(operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("dataMissing 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case .decoding(.dataMissing) = error else { + Issue.record("dataMissing 에러여야 합니다: \(error)") + return + } + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Perspective/Project.swift b/Projects/Domain/Perspective/Project.swift deleted file mode 100644 index bcee21a3..00000000 --- a/Projects/Domain/Perspective/Project.swift +++ /dev/null @@ -1,23 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .microModule(name: "PerspectiveDomain"), - bundleId: .appBundleID(name: ".PerspectiveDomain"), - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .SPM.weaveDI, - .SPM.composableArchitecture, - ], - interfaceDependencies: [ - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .SPM.weaveDI, - .SPM.composableArchitecture, - ] -) diff --git a/Projects/Domain/Perspective/Testing/PerspectiveDomainTesting.swift b/Projects/Domain/Perspective/Testing/PerspectiveDomainTesting.swift deleted file mode 100644 index 8ce47b3f..00000000 --- a/Projects/Domain/Perspective/Testing/PerspectiveDomainTesting.swift +++ /dev/null @@ -1,4 +0,0 @@ -import PerspectiveDomainInterface - -/// Perspective 도메인 목(mock) 배치용 네임스페이스. (현재 목 없음) -public enum PerspectiveDomainTesting {} diff --git a/Projects/Domain/Perspective/Interface/DefaultPerspectiveRepositoryImpl.swift b/Projects/Domain/PerspectiveDomain/Interface/Sources/MockPerspectiveRepository.swift similarity index 94% rename from Projects/Domain/Perspective/Interface/DefaultPerspectiveRepositoryImpl.swift rename to Projects/Domain/PerspectiveDomain/Interface/Sources/MockPerspectiveRepository.swift index 95b8363f..28712b59 100644 --- a/Projects/Domain/Perspective/Interface/DefaultPerspectiveRepositoryImpl.swift +++ b/Projects/Domain/PerspectiveDomain/Interface/Sources/MockPerspectiveRepository.swift @@ -1,15 +1,15 @@ // -// DefaultPerspectiveRepositoryImpl.swift +// MockPerspectiveRepository.swift // PerspectiveDomainInterface // // Created by Wonji Suh on 6/3/26. // +import BattleDomainInterface import CommentDomainInterface -import CommonDomainInterface import Foundation -public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { +public struct MockPerspectiveRepository: PerspectiveInterface { public init() {} public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { diff --git a/Projects/Domain/Perspective/Interface/PerspectiveError.swift b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift similarity index 100% rename from Projects/Domain/Perspective/Interface/PerspectiveError.swift rename to Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift diff --git a/Projects/Domain/Perspective/Interface/PerspectiveInterface.swift b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveInterface.swift similarity index 74% rename from Projects/Domain/Perspective/Interface/PerspectiveInterface.swift rename to Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveInterface.swift index a7f59fc6..1415b56d 100644 --- a/Projects/Domain/Perspective/Interface/PerspectiveInterface.swift +++ b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveInterface.swift @@ -4,10 +4,10 @@ // import CommentDomainInterface -import CommonDomainInterface +import BattleDomainInterface import Dependencies import Foundation -import WeaveDI +import ComposableArchitecture public protocol PerspectiveInterface: Sendable { func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective @@ -38,16 +38,12 @@ public protocol PerspectiveInterface: Sendable { func reportComment(perspectiveId: Int, commentId: Int) async throws } -public struct PerspectiveRepositoryDependency: DependencyKey { - public static var liveValue: PerspectiveInterface { - UnifiedDI.resolve(PerspectiveInterface.self) ?? DefaultPerspectiveRepositoryImpl() - } - - public static var testValue: PerspectiveInterface { - UnifiedDI.resolve(PerspectiveInterface.self) ?? DefaultPerspectiveRepositoryImpl() - } +public enum PerspectiveRepositoryDependency: TestDependencyKey { + public static var testValue: PerspectiveInterface { MockPerspectiveRepository() } +} - public static var previewValue: PerspectiveInterface = liveValue +public enum PerspectiveUseCaseDependency: TestDependencyKey { + public static var testValue: PerspectiveInterface { MockPerspectiveRepository() } } public extension DependencyValues { @@ -60,7 +56,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var perspectiveUseCase: PerspectiveInterface { - get { self[PerspectiveRepositoryDependency.self] } - set { self[PerspectiveRepositoryDependency.self] = newValue } + get { self[PerspectiveUseCaseDependency.self] } + set { self[PerspectiveUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/PerspectiveDomain/Project.swift b/Projects/Domain/PerspectiveDomain/Project.swift new file mode 100644 index 00000000..762744da --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Project.swift @@ -0,0 +1,27 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PerspectiveDomain", + bundleId: .appBundleID(name: ".PerspectiveDomain"), + product: .framework, + settings: .settings(), + dependencies: [ + .serviceAssembly, + .domain(.battle, .interface), + .domain(.comment, .interface), + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .domain(.battle, .interface), + .domain(.comment, .interface), + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift b/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveCommentDataDTO.swift similarity index 75% rename from Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift rename to Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveCommentDataDTO.swift index 2c1c589d..ddabcf80 100644 --- a/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift +++ b/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveCommentDataDTO.swift @@ -1,10 +1,12 @@ // // PerspectiveCommentDataDTO.swift -// Model +// PerspectiveDomain // import Foundation +import PickeNetworkInterface + public struct PerspectiveCommentPageDataDTO: Decodable { public let items: [PerspectiveCommentDTO] public let nextCursor: String? @@ -36,6 +38,3 @@ public struct PerspectiveCommentMutationDataDTO: Decodable { public let updatedAt: String? } -public typealias PerspectiveCommentPageResponseDTO = BaseResponseDTO -public typealias PerspectiveCommentMutationResponseDTO = BaseResponseDTO -public typealias PerspectiveDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveDetailDataDTO.swift b/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveDetailDataDTO.swift new file mode 100644 index 00000000..d64d9da8 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveDetailDataDTO.swift @@ -0,0 +1,39 @@ +// +// PerspectiveDetailDataDTO.swift +// PerspectiveDomain +// + +import Foundation + +import PickeNetworkInterface + +/// 관점 상세 응답 페이로드. +/// +/// 배틀 목록의 관점(`BattlePerspectiveDTO`) 과 와이어 포맷은 같지만, +/// 이 엔드포인트는 PerspectiveDomain 소유이므로 DTO 도 여기서 따로 갖는다. +/// (도메인끼리 서로의 구현 모듈을 참조하지 않기 위함 — 계약은 Interface 의 `BattlePerspective` 로만 오간다.) +public struct PerspectiveDetailDataDTO: Decodable { + public let perspectiveId: Int + public let user: PerspectiveDetailUserDTO + public let option: PerspectiveDetailOptionDTO + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool? + public let isMyPerspective: Bool? + public let createdAt: String? +} + +public struct PerspectiveDetailUserDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +public struct PerspectiveDetailOptionDTO: Decodable { + public let optionId: Int + public let label: String? + public let title: String? + public let stance: String? +} diff --git a/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveLikeDataDTO.swift b/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveLikeDataDTO.swift new file mode 100644 index 00000000..a660a8c1 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/Model/DTO/PerspectiveLikeDataDTO.swift @@ -0,0 +1,16 @@ +// +// PerspectiveLikeDataDTO.swift +// PerspectiveDomain +// + +import Foundation + +import PickeNetworkInterface + +/// 관점 좋아요/취소/조회 응답 페이로드. +public struct PerspectiveLikeDataDTO: Decodable { + public let perspectiveId: Int + public let likeCount: Int + /// perspective 좋아요 응답에는 isLiked 가 없어 옵셔널. + public let isLiked: Bool? +} diff --git a/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveCommentDataDTO+.swift b/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveCommentDataDTO+.swift new file mode 100644 index 00000000..e1243cb9 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveCommentDataDTO+.swift @@ -0,0 +1,55 @@ +// +// PerspectiveCommentDataDTO+.swift +// PerspectiveDomain +// + +import CommentDomainInterface +import Foundation + +import PickeCoreUtility + +public extension PerspectiveCommentPageDataDTO { + func toDomain() -> PerspectiveCommentPage { + PerspectiveCommentPage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension PerspectiveCommentDTO { + func toDomain() -> PerspectiveComment { + PerspectiveComment( + commentId: commentId, + user: user.toDomain(), + stance: stance ?? "", + content: content, + likeCount: likeCount, + isLiked: isLiked ?? false, + isMine: isMine ?? false, + createdAt: createdAt.flatMap(ServerDateParser.parse) + ) + } +} + +public extension PerspectiveCommentUserDTO { + func toDomain() -> PerspectiveCommentUser { + PerspectiveCommentUser( + userTag: userTag ?? "", + nickname: nickname ?? "익명", + characterType: characterType ?? "", + characterImageUrl: characterImageUrl + ) + } +} + +public extension PerspectiveCommentMutationDataDTO { + func toDomain() -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult( + commentId: commentId, + content: content, + updatedAt: updatedAt.flatMap(ServerDateParser.parse) + ) + } +} diff --git a/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveDetailDataDTO+.swift b/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveDetailDataDTO+.swift new file mode 100644 index 00000000..02e3cdc1 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveDetailDataDTO+.swift @@ -0,0 +1,47 @@ +// +// PerspectiveDetailDataDTO+.swift +// PerspectiveDomain +// + +import Foundation + +import BattleDomainInterface +import PickeCoreUtility + +public extension PerspectiveDetailDataDTO { + func toDomain() -> BattlePerspective { + BattlePerspective( + perspectiveId: perspectiveId, + user: user.toDomain(), + option: option.toDomain(), + content: content, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked ?? false, + isMyPerspective: isMyPerspective ?? false, + createdAt: createdAt.flatMap(ServerDateParser.parse) + ) + } +} + +public extension PerspectiveDetailUserDTO { + func toDomain() -> BattlePerspectiveUser { + BattlePerspectiveUser( + userTag: userTag ?? "", + nickname: nickname ?? "익명", + characterType: characterType ?? "", + characterImageUrl: characterImageUrl + ) + } +} + +public extension PerspectiveDetailOptionDTO { + func toDomain() -> BattlePerspectiveOption { + BattlePerspectiveOption( + optionId: optionId, + label: label, + title: title ?? "", + stance: stance ?? "" + ) + } +} diff --git a/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveLikeDataDTO+.swift b/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveLikeDataDTO+.swift new file mode 100644 index 00000000..39f9d409 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/Model/Mapper/PerspectiveLikeDataDTO+.swift @@ -0,0 +1,18 @@ +// +// PerspectiveLikeDataDTO+.swift +// PerspectiveDomain +// + +import Foundation + +import CommentDomainInterface + +public extension PerspectiveLikeDataDTO { + func toDomain() -> CommentLikeResult { + CommentLikeResult( + perspectiveId: perspectiveId, + likeCount: likeCount, + isLiked: isLiked ?? false + ) + } +} diff --git a/Projects/Domain/PerspectiveDomain/Sources/PerspectiveLiveDependencies.swift b/Projects/Domain/PerspectiveDomain/Sources/PerspectiveLiveDependencies.swift new file mode 100644 index 00000000..b07ae883 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/PerspectiveLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// PerspectiveLiveDependencies.swift +// PerspectiveDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import PerspectiveDomainInterface +import ComposableArchitecture + +extension PerspectiveUseCaseDependency: DependencyKey { + public static var liveValue: PerspectiveInterface { PerspectiveUseCaseImpl() } +} + +extension PerspectiveRepositoryDependency: DependencyKey { + public static var liveValue: PerspectiveInterface { PerspectiveRepositoryImpl() } +} diff --git a/Projects/Domain/Perspective/Sources/PerspectiveUseCase.swift b/Projects/Domain/PerspectiveDomain/Sources/PerspectiveUseCase.swift similarity index 98% rename from Projects/Domain/Perspective/Sources/PerspectiveUseCase.swift rename to Projects/Domain/PerspectiveDomain/Sources/PerspectiveUseCase.swift index 1d065244..fa627ae3 100644 --- a/Projects/Domain/Perspective/Sources/PerspectiveUseCase.swift +++ b/Projects/Domain/PerspectiveDomain/Sources/PerspectiveUseCase.swift @@ -5,8 +5,8 @@ import Foundation +import BattleDomainInterface import CommentDomainInterface -import CommonDomainInterface import PerspectiveDomainInterface import ComposableArchitecture diff --git a/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift b/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift new file mode 100644 index 00000000..c760f436 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift @@ -0,0 +1,133 @@ +// +// PerspectiveRepositoryImpl.swift +// Repository +// + +import Foundation + +import Dependencies + +import APIEndpoint +import BattleDomainInterface +import CommentDomainInterface +import PickeNetwork +import PerspectiveDomainInterface + + +public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + let data = try await client.send( + PerspectiveService.detail(perspectiveId: perspectiveId), + as: PerspectiveDetailDataDTO.self + ) + + return data.toDomain() + } + + public func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + let data = try await client.send( + PerspectiveService.listLabeledComments(perspectiveId: perspectiveId, cursor: cursor, size: size), + as: PerspectiveCommentPageDataDTO.self + ) + + return data.toDomain() + } + + public func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + let data = try await client.send( + PerspectiveService.createComment(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)), + as: PerspectiveCommentMutationDataDTO.self + ) + + return data.toDomain() + } + + public func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + let data = try await client.send( + PerspectiveService.updateComment( perspectiveId: perspectiveId, commentId: commentId, body: PerspectiveCommentBody(content: content) ), + as: PerspectiveCommentMutationDataDTO.self + ) + + return data.toDomain() + } + + public func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { + _ = try await client.send( + PerspectiveService.deleteComment(perspectiveId: perspectiveId, commentId: commentId), + as: PickeEmptyResponse.self + ) + } + + public func updatePerspective(perspectiveId: Int, content: String) async throws { + _ = try await client.send( + PerspectiveService.updatePerspective(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)), + as: PickeEmptyResponse.self + ) + } + + public func deletePerspective(perspectiveId: Int) async throws { + _ = try await client.send( + PerspectiveService.deletePerspective(perspectiveId: perspectiveId), + as: PickeEmptyResponse.self + ) + } + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + let data = try await client.send( + PerspectiveService.likePerspective(perspectiveId: perspectiveId), + as: PerspectiveLikeDataDTO.self + ) + + return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: true) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + let data = try await client.send( + PerspectiveService.unlikePerspective(perspectiveId: perspectiveId), + as: PerspectiveLikeDataDTO.self + ) + + return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: false) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + let data = try await client.send( + PerspectiveService.fetchPerspectiveLikes(perspectiveId: perspectiveId), + as: PerspectiveLikeDataDTO.self + ) + + return data.toDomain() + } + + public func reportPerspective(perspectiveId: Int) async throws { + _ = try await client.send( + PerspectiveService.reportPerspective(perspectiveId: perspectiveId), + as: PickeEmptyResponse.self + ) + } + + public func reportComment(perspectiveId: Int, commentId: Int) async throws { + _ = try await client.send( + PerspectiveService.reportComment(perspectiveId: perspectiveId, commentId: commentId), + as: PickeEmptyResponse.self + ) + } +} diff --git a/Projects/Domain/Perspective/Tests/PerspectiveDomainTests.swift b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveDomainTests.swift similarity index 100% rename from Projects/Domain/Perspective/Tests/PerspectiveDomainTests.swift rename to Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveDomainTests.swift diff --git a/Projects/Data/Perspective/Tests/PerspectiveRepositoryTests.swift b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift similarity index 58% rename from Projects/Data/Perspective/Tests/PerspectiveRepositoryTests.swift rename to Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift index 5a55f428..efec2f4b 100644 --- a/Projects/Data/Perspective/Tests/PerspectiveRepositoryTests.swift +++ b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift @@ -4,12 +4,14 @@ // import Foundation + +import Dependencies import Testing -@testable import PerspectiveData +@testable import PerspectiveDomain +import APIEndpoint import CommentDomainInterface -import CommonDomainInterface import PerspectiveDomainInterface struct PerspectiveRepositoryTests { @@ -44,9 +46,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.fetchPerspective(perspectiveId: 42) @@ -62,7 +66,7 @@ struct PerspectiveRepositoryTests { } @Test - func fetchPerspective_emptyData_throwsBackendErrorWithMessage() async throws { + func fetchPerspective_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 404, @@ -70,20 +74,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "PERSPECTIVE_404", "message": "존재하지 않는 관점입니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: PerspectiveError.backendError("존재하지 않는 관점입니다")) { + await expectNetworkResponseError( + statusCode: 404, + code: "PERSPECTIVE_404", + message: "존재하지 않는 관점입니다" + ) { try await repo.fetchPerspective(perspectiveId: 42) } } @Test func fetchPerspective_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.fetchPerspective(perspectiveId: 42) @@ -121,9 +133,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.fetchLabeledComments(perspectiveId: 42, cursor: nil, size: 20) @@ -148,9 +162,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.fetchLabeledComments(perspectiveId: 42, cursor: nil, size: nil) @@ -160,7 +176,7 @@ struct PerspectiveRepositoryTests { } @Test - func fetchLabeledComments_emptyData_throwsBackendErrorWithDefaultMessage() async throws { + func fetchLabeledComments_emptyData_throwsNetworkDataMissing() async throws { let json = """ { "statusCode": 500, @@ -168,20 +184,24 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("대댓글 목록 응답이 비어 있습니다")) { + await expectNetworkDataMissing { try await repo.fetchLabeledComments(perspectiveId: 42, cursor: nil, size: nil) } } @Test func fetchLabeledComments_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.fetchLabeledComments(perspectiveId: 42, cursor: nil, size: nil) @@ -203,9 +223,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.createComment(perspectiveId: 42, content: "새 댓글") @@ -213,7 +235,7 @@ struct PerspectiveRepositoryTests { } @Test - func createComment_emptyData_throwsBackendErrorWithMessage() async throws { + func createComment_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -221,20 +243,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "COMMENT_400", "message": "댓글 작성에 실패했습니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("댓글 작성에 실패했습니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "COMMENT_400", + message: "댓글 작성에 실패했습니다" + ) { try await repo.createComment(perspectiveId: 42, content: "새 댓글") } } @Test func createComment_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.createComment(perspectiveId: 42, content: "새 댓글") @@ -256,9 +286,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.updateComment(perspectiveId: 42, commentId: 9, content: "수정된 댓글") @@ -268,7 +300,7 @@ struct PerspectiveRepositoryTests { } @Test - func updateComment_emptyData_throwsBackendErrorWithMessage() async throws { + func updateComment_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -276,20 +308,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "COMMENT_400", "message": "댓글 수정에 실패했습니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("댓글 수정에 실패했습니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "COMMENT_400", + message: "댓글 수정에 실패했습니다" + ) { try await repo.updateComment(perspectiveId: 42, commentId: 9, content: "수정된 댓글") } } @Test func updateComment_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.updateComment(perspectiveId: 42, commentId: 9, content: "수정된 댓글") @@ -307,15 +347,17 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } try await repo.deleteComment(perspectiveId: 42, commentId: 9) } @Test - func deleteComment_statusCode400_throwsBackendErrorWithMessage() async throws { + func deleteComment_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -323,20 +365,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "COMMENT_400", "message": "댓글 삭제에 실패했습니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("댓글 삭제에 실패했습니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "COMMENT_400", + message: "댓글 삭제에 실패했습니다" + ) { try await repo.deleteComment(perspectiveId: 42, commentId: 9) } } @Test func deleteComment_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.deleteComment(perspectiveId: 42, commentId: 9) @@ -354,15 +404,17 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } try await repo.updatePerspective(perspectiveId: 42, content: "관점 수정 내용") } @Test - func updatePerspective_statusCode400_throwsBackendErrorWithMessage() async throws { + func updatePerspective_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -370,20 +422,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "PERSPECTIVE_400", "message": "관점 수정에 실패했습니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: PerspectiveError.backendError("관점 수정에 실패했습니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "PERSPECTIVE_400", + message: "관점 수정에 실패했습니다" + ) { try await repo.updatePerspective(perspectiveId: 42, content: "관점 수정 내용") } } @Test func updatePerspective_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.updatePerspective(perspectiveId: 42, content: "관점 수정 내용") @@ -401,15 +461,17 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } try await repo.deletePerspective(perspectiveId: 42) } @Test - func deletePerspective_statusCode400_throwsBackendErrorWithMessage() async throws { + func deletePerspective_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -417,20 +479,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "PERSPECTIVE_400", "message": "관점 삭제에 실패했습니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: PerspectiveError.backendError("관점 삭제에 실패했습니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "PERSPECTIVE_400", + message: "관점 삭제에 실패했습니다" + ) { try await repo.deletePerspective(perspectiveId: 42) } } @Test func deletePerspective_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.deletePerspective(perspectiveId: 42) @@ -452,9 +522,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.likePerspective(perspectiveId: 42) @@ -462,7 +534,7 @@ struct PerspectiveRepositoryTests { } @Test - func likePerspective_emptyData_throwsBackendErrorWithMessage() async throws { + func likePerspective_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, @@ -470,20 +542,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "PERSPECTIVE_400", "message": "이미 좋아요한 관점입니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("이미 좋아요한 관점입니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "PERSPECTIVE_400", + message: "이미 좋아요한 관점입니다" + ) { try await repo.likePerspective(perspectiveId: 42) } } @Test func likePerspective_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.likePerspective(perspectiveId: 42) @@ -505,9 +585,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.unlikePerspective(perspectiveId: 42) @@ -515,7 +597,7 @@ struct PerspectiveRepositoryTests { } @Test - func unlikePerspective_emptyData_throwsBackendErrorWithDefaultMessage() async throws { + func unlikePerspective_emptyData_throwsNetworkDataMissing() async throws { let json = """ { "statusCode": 500, @@ -523,20 +605,24 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("관점 좋아요 취소 응답이 비어 있습니다")) { + await expectNetworkDataMissing { try await repo.unlikePerspective(perspectiveId: 42) } } @Test func unlikePerspective_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.unlikePerspective(perspectiveId: 42) @@ -558,9 +644,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.fetchPerspectiveLikes(perspectiveId: 42) @@ -580,9 +668,11 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } let result = try await repo.fetchPerspectiveLikes(perspectiveId: 42) @@ -590,7 +680,7 @@ struct PerspectiveRepositoryTests { } @Test - func fetchPerspectiveLikes_emptyData_throwsBackendErrorWithMessage() async throws { + func fetchPerspectiveLikes_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 404, @@ -598,20 +688,28 @@ struct PerspectiveRepositoryTests { "error": { "code": "PERSPECTIVE_404", "message": "좋아요 정보를 찾을 수 없습니다" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("좋아요 정보를 찾을 수 없습니다")) { + await expectNetworkResponseError( + statusCode: 404, + code: "PERSPECTIVE_404", + message: "좋아요 정보를 찾을 수 없습니다" + ) { try await repo.fetchPerspectiveLikes(perspectiveId: 42) } } @Test func fetchPerspectiveLikes_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.fetchPerspectiveLikes(perspectiveId: 42) @@ -629,36 +727,46 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } try await repo.reportPerspective(perspectiveId: 42) } @Test - func reportPerspective_statusCode400_throwsBackendErrorWithDefaultMessage() async throws { + func reportPerspective_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, "data": null, - "error": null + "error": { "code": "PERSPECTIVE_400", "message": "관점 신고 실패" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("관점 신고 실패")) { + await expectNetworkResponseError( + statusCode: 400, + code: "PERSPECTIVE_400", + message: "관점 신고 실패" + ) { try await repo.reportPerspective(perspectiveId: 42) } } @Test func reportPerspective_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.reportPerspective(perspectiveId: 42) @@ -676,36 +784,46 @@ struct PerspectiveRepositoryTests { "error": null } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } try await repo.reportComment(perspectiveId: 42, commentId: 9) } @Test - func reportComment_statusCode400_throwsBackendErrorWithDefaultMessage() async throws { + func reportComment_errorEnvelope_throwsNetworkResponseError() async throws { let json = """ { "statusCode": 400, "data": null, - "error": null + "error": { "code": "COMMENT_400", "message": "댓글 신고 실패" } } """ - let repo = PerspectiveRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } - await #expect(throws: CommentError.backendError("댓글 신고 실패")) { + await expectNetworkResponseError( + statusCode: 400, + code: "COMMENT_400", + message: "댓글 신고 실패" + ) { try await repo.reportComment(perspectiveId: 42, commentId: 9) } } @Test func reportComment_networkFailure_throws() async throws { - let repo = PerspectiveRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + PerspectiveRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.reportComment(perspectiveId: 42, commentId: 9) diff --git a/Projects/Data/Perspective/Tests/PerspectiveRequestMappingTests.swift b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRequestMappingTests.swift similarity index 88% rename from Projects/Data/Perspective/Tests/PerspectiveRequestMappingTests.swift rename to Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRequestMappingTests.swift index 13c1b2dc..b7463c43 100644 --- a/Projects/Data/Perspective/Tests/PerspectiveRequestMappingTests.swift +++ b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRequestMappingTests.swift @@ -6,9 +6,10 @@ import Foundation import Testing -@testable import PerspectiveData +@testable import PerspectiveDomain -import NetworkHeader +import APIEndpoint +@testable import PickeNetwork struct PerspectiveRequestMappingTests { // MARK: - detail @@ -245,11 +246,24 @@ struct PerspectiveRequestMappingTests { // MARK: - headers - @Test func 모든_요청은_baseHeader_를_포함한다() throws { - let request = try PerspectiveService.detail(perspectiveId: 42).asURLRequest() - - #expect(request.value(forHTTPHeaderField: "Content-Type") != nil) - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer") == true) - #expect(request.value(forHTTPHeaderField: "accept") != nil) + @Test func 모든_요청은_자동_인증_정책을_사용한다() { + let services: [PerspectiveService] = [ + .detail(perspectiveId: 42), + .listLabeledComments(perspectiveId: 42, cursor: nil, size: nil), + .createComment(perspectiveId: 42, body: PerspectiveCommentBody(content: "댓글")), + .updateComment(perspectiveId: 42, commentId: 7, body: PerspectiveCommentBody(content: "댓글")), + .deleteComment(perspectiveId: 42, commentId: 7), + .updatePerspective(perspectiveId: 42, body: PerspectiveCommentBody(content: "관점")), + .deletePerspective(perspectiveId: 42), + .likePerspective(perspectiveId: 42), + .unlikePerspective(perspectiveId: 42), + .fetchPerspectiveLikes(perspectiveId: 42), + .reportPerspective(perspectiveId: 42), + .reportComment(perspectiveId: 42, commentId: 7), + ] + + for service in services { + #expect(service.authorization == .automatic) + } } } diff --git a/Projects/Domain/PerspectiveDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/PerspectiveDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..55758ad9 --- /dev/null +++ b/Projects/Domain/PerspectiveDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,141 @@ +// +// TestSupport.swift +// PerspectiveDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} + +func expectNetworkDataMissing(operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("dataMissing 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case .decoding(.dataMissing) = error else { + Issue.record("dataMissing 에러여야 합니다: \(error)") + return + } + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Profile/Testing/ProfileDomainTesting.swift b/Projects/Domain/Profile/Testing/ProfileDomainTesting.swift deleted file mode 100644 index 756bb99e..00000000 --- a/Projects/Domain/Profile/Testing/ProfileDomainTesting.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// ProfileDomainTesting.swift -// ProfileDomainTesting -// - -import ProfileDomainInterface - -public enum ProfileDomainTesting {} diff --git a/Projects/Domain/Profile/Interface/Entity/BattleRecord/BattleRecord.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/BattleRecord/BattleRecord.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/BattleRecord/BattleRecord.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/BattleRecord/BattleRecord.swift diff --git a/Projects/Domain/Profile/Interface/Entity/BattleRecord/BattleRecordPage.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/BattleRecord/BattleRecordPage.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/BattleRecord/BattleRecordPage.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/BattleRecord/BattleRecordPage.swift diff --git a/Projects/Domain/Profile/Interface/Entity/BattleRecord/BattleVoteSide.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/BattleRecord/BattleVoteSide.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/BattleRecord/BattleVoteSide.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/BattleRecord/BattleVoteSide.swift diff --git a/Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivity.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivity.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivity.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivity.swift diff --git a/Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivityAuthor.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivityAuthor.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivityAuthor.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivityAuthor.swift diff --git a/Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivityPage.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivityPage.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivityPage.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivityPage.swift diff --git a/Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivityType.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivityType.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/ContentActivity/ContentActivityType.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/ContentActivity/ContentActivityType.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Credit/CreditHistoryItem.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Credit/CreditHistoryItem.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Credit/CreditHistoryItem.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Credit/CreditHistoryItem.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Credit/CreditHistoryPage.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Credit/CreditHistoryPage.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Credit/CreditHistoryPage.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Credit/CreditHistoryPage.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Error/ProfileError.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Error/ProfileError.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Error/ProfileError.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Error/ProfileError.swift diff --git a/Projects/Domain/Profile/Interface/Entity/MyPage/MyPage.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyPage.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/MyPage/MyPage.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyPage.swift diff --git a/Projects/Domain/Profile/Interface/Entity/MyPage/MyPhilosopher.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyPhilosopher.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/MyPage/MyPhilosopher.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyPhilosopher.swift diff --git a/Projects/Domain/Profile/Interface/Entity/MyPage/MyProfile.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyProfile.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/MyPage/MyProfile.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyProfile.swift diff --git a/Projects/Domain/Profile/Interface/Entity/MyPage/MyTier.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyTier.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/MyPage/MyTier.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/MyTier.swift diff --git a/Projects/Domain/Profile/Interface/Entity/MyPage/UpdatedProfile.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/UpdatedProfile.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/MyPage/UpdatedProfile.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/MyPage/UpdatedProfile.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Notice/NoticeTab.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notice/NoticeTab.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Notice/NoticeTab.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notice/NoticeTab.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Notification/NotificationSettingKey.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notification/NotificationSettingKey.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Notification/NotificationSettingKey.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notification/NotificationSettingKey.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Notification/NotificationSettingSection.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notification/NotificationSettingSection.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Notification/NotificationSettingSection.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notification/NotificationSettingSection.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Notification/NotificationSettings.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notification/NotificationSettings.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Notification/NotificationSettings.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Notification/NotificationSettings.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Recap/FavoriteTopic.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/FavoriteTopic.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Recap/FavoriteTopic.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/FavoriteTopic.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Recap/PhilosopherRecap.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/PhilosopherRecap.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Recap/PhilosopherRecap.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/PhilosopherRecap.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Recap/PreferenceReport.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/PreferenceReport.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Recap/PreferenceReport.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/PreferenceReport.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Recap/RecapCard.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/RecapCard.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Recap/RecapCard.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/RecapCard.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Recap/RecapScoreAxis.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/RecapScoreAxis.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Recap/RecapScoreAxis.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/RecapScoreAxis.swift diff --git a/Projects/Domain/Profile/Interface/Entity/Recap/RecapScores.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/RecapScores.swift similarity index 100% rename from Projects/Domain/Profile/Interface/Entity/Recap/RecapScores.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Entity/Recap/RecapScores.swift diff --git a/Projects/Domain/Profile/Interface/Repository/Default/DefaultProfileRepositoryImpl.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Repository/Default/MockProfileRepository.swift similarity index 96% rename from Projects/Domain/Profile/Interface/Repository/Default/DefaultProfileRepositoryImpl.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Repository/Default/MockProfileRepository.swift index 964d032b..8b001471 100644 --- a/Projects/Domain/Profile/Interface/Repository/Default/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/ProfileDomain/Interface/Sources/Repository/Default/MockProfileRepository.swift @@ -1,11 +1,11 @@ // -// DefaultProfileRepositoryImpl.swift +// MockProfileRepository.swift // DomainInterface // import Foundation -public struct DefaultProfileRepositoryImpl: ProfileInterface { +public struct MockProfileRepository: ProfileInterface { public init() {} public func fetchMyPage() async throws -> MyPage { diff --git a/Projects/Domain/Profile/Interface/Repository/Interface/ProfileInterface.swift b/Projects/Domain/ProfileDomain/Interface/Sources/Repository/ProfileInterface.swift similarity index 71% rename from Projects/Domain/Profile/Interface/Repository/Interface/ProfileInterface.swift rename to Projects/Domain/ProfileDomain/Interface/Sources/Repository/ProfileInterface.swift index 16024d9d..f18507bd 100644 --- a/Projects/Domain/Profile/Interface/Repository/Interface/ProfileInterface.swift +++ b/Projects/Domain/ProfileDomain/Interface/Sources/Repository/ProfileInterface.swift @@ -4,7 +4,7 @@ // import Foundation -import WeaveDI +import ComposableArchitecture public protocol ProfileInterface: Sendable { func fetchMyPage() async throws -> MyPage @@ -31,16 +31,12 @@ public protocol ProfileInterface: Sendable { ) async throws -> UpdatedProfile } -public struct ProfileRepositoryDependency: DependencyKey { - public static var liveValue: ProfileInterface { - UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() - } - - public static var testValue: ProfileInterface { - UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() - } +public enum ProfileRepositoryDependency: TestDependencyKey { + public static var testValue: ProfileInterface { MockProfileRepository() } +} - public static var previewValue: ProfileInterface = liveValue +public enum ProfileUseCaseDependency: TestDependencyKey { + public static var testValue: ProfileInterface { MockProfileRepository() } } public extension DependencyValues { @@ -53,7 +49,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var profileUseCase: ProfileInterface { - get { self[ProfileRepositoryDependency.self] } - set { self[ProfileRepositoryDependency.self] = newValue } + get { self[ProfileUseCaseDependency.self] } + set { self[ProfileUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/Profile/Project.swift b/Projects/Domain/ProfileDomain/Project.swift similarity index 62% rename from Projects/Domain/Profile/Project.swift rename to Projects/Domain/ProfileDomain/Project.swift index 15f54892..952eef77 100644 --- a/Projects/Domain/Profile/Project.swift +++ b/Projects/Domain/ProfileDomain/Project.swift @@ -1,19 +1,23 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .microModule(name: "ProfileDomain"), +import ProjectDescription + +let project = Project.makeModule( + name: "ProfileDomain", bundleId: .appBundleID(name: ".ProfileDomain"), - product: .staticFramework, + product: .framework, settings: .settings(), dependencies: [ - .SPM.composableArchitecture, + .serviceAssembly, ], + hasTests: true, + hasInterface: true, interfaceDependencies: [ - .SPM.weaveDI, .SPM.composableArchitecture, - ] -) + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Domain/Profile/Sources/Exported/ProfileDomainExported.swift b/Projects/Domain/ProfileDomain/Sources/Exported/ProfileDomainExported.swift similarity index 100% rename from Projects/Domain/Profile/Sources/Exported/ProfileDomainExported.swift rename to Projects/Domain/ProfileDomain/Sources/Exported/ProfileDomainExported.swift diff --git a/Projects/Data/Profile/Sources/Model/DTO/BattleRecordDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/BattleRecordDataDTO.swift similarity index 81% rename from Projects/Data/Profile/Sources/Model/DTO/BattleRecordDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/BattleRecordDataDTO.swift index 1bbf1246..dbdbeb40 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/BattleRecordDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/BattleRecordDataDTO.swift @@ -1,10 +1,10 @@ // // BattleRecordDataDTO.swift -// Model +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct BattleRecordDataDTO: Decodable { public let items: [BattleRecordItemDTO]? @@ -21,5 +21,3 @@ public struct BattleRecordItemDTO: Decodable { public let summary: String? public let createdAt: String? } - -public typealias BattleRecordResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/DTO/ContentActivityDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/ContentActivityDataDTO.swift similarity index 87% rename from Projects/Data/Profile/Sources/Model/DTO/ContentActivityDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/ContentActivityDataDTO.swift index 2b726521..a7326720 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/ContentActivityDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/ContentActivityDataDTO.swift @@ -1,10 +1,10 @@ // // ContentActivityDataDTO.swift -// Model +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct ContentActivityDataDTO: Decodable { public let items: [ContentActivityItemDTO]? @@ -31,5 +31,3 @@ public struct ContentActivityAuthorDTO: Decodable { public let characterType: String? public let characterImageUrl: String? } - -public typealias ContentActivityResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/DTO/CreditHistoryDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/CreditHistoryDataDTO.swift similarity index 79% rename from Projects/Data/Profile/Sources/Model/DTO/CreditHistoryDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/CreditHistoryDataDTO.swift index 6e3cc998..a6a230fd 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/CreditHistoryDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/CreditHistoryDataDTO.swift @@ -1,10 +1,10 @@ // // CreditHistoryDataDTO.swift -// Model +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct CreditHistoryDataDTO: Decodable { public let items: [CreditHistoryItemDTO]? @@ -19,5 +19,3 @@ public struct CreditHistoryItemDTO: Decodable { public let referenceId: Int? public let createdAt: String? } - -public typealias CreditHistoryResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/DTO/MyPageDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/MyPageDataDTO.swift similarity index 90% rename from Projects/Data/Profile/Sources/Model/DTO/MyPageDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/MyPageDataDTO.swift index c4a8b7aa..fe691526 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/MyPageDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/MyPageDataDTO.swift @@ -1,10 +1,10 @@ // // MyPageDataDTO.swift -// Model +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct MyPageDataDTO: Decodable { // philosopher 는 미확정(배틀 5개 미만) 시 null 로 내려와 옵셔널로 둔다. @@ -35,5 +35,3 @@ public struct MyTierDTO: Decodable { public let tierLabel: String? public let currentPoint: Int? } - -public typealias MyPageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/DTO/NotificationSettingsDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/NotificationSettingsDataDTO.swift similarity index 74% rename from Projects/Data/Profile/Sources/Model/DTO/NotificationSettingsDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/NotificationSettingsDataDTO.swift index 180f9500..6f4a6dea 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/NotificationSettingsDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/NotificationSettingsDataDTO.swift @@ -1,10 +1,10 @@ // // NotificationSettingsDataDTO.swift -// Model +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct NotificationSettingsDataDTO: Decodable { public let newBattleEnabled: Bool? @@ -14,5 +14,3 @@ public struct NotificationSettingsDataDTO: Decodable { public let contentLikeEnabled: Bool? public let marketingEventEnabled: Bool? } - -public typealias NotificationSettingsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/DTO/ProfileUpdateDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/ProfileUpdateDataDTO.swift similarity index 67% rename from Projects/Data/Profile/Sources/Model/DTO/ProfileUpdateDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/ProfileUpdateDataDTO.swift index 4da385b5..7630ca60 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/ProfileUpdateDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/ProfileUpdateDataDTO.swift @@ -1,10 +1,10 @@ // // ProfileUpdateDataDTO.swift -// ProfileData +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct ProfileUpdateDataDTO: Decodable { public let userTag: String? @@ -12,5 +12,3 @@ public struct ProfileUpdateDataDTO: Decodable { public let characterType: String? public let updatedAt: String? } - -public typealias ProfileUpdateResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/DTO/RecapDataDTO.swift b/Projects/Domain/ProfileDomain/Sources/Model/DTO/RecapDataDTO.swift similarity index 92% rename from Projects/Data/Profile/Sources/Model/DTO/RecapDataDTO.swift rename to Projects/Domain/ProfileDomain/Sources/Model/DTO/RecapDataDTO.swift index 73a915a0..de2b0ce9 100644 --- a/Projects/Data/Profile/Sources/Model/DTO/RecapDataDTO.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/DTO/RecapDataDTO.swift @@ -1,10 +1,10 @@ // // RecapDataDTO.swift -// Model +// ProfileDomain // +import PickeNetworkInterface import Foundation -import Model public struct RecapDataDTO: Decodable { public let myCard: RecapCardDTO? @@ -44,5 +44,3 @@ public struct FavoriteTopicDTO: Decodable { public let participationCount: Int? public let tagName: String? } - -public typealias RecapResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Profile/Sources/Model/Mapper/BattleRecordDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/BattleRecordDataDTO+.swift similarity index 98% rename from Projects/Data/Profile/Sources/Model/Mapper/BattleRecordDataDTO+.swift rename to Projects/Domain/ProfileDomain/Sources/Model/Mapper/BattleRecordDataDTO+.swift index 1988e450..5a6cd390 100644 --- a/Projects/Data/Profile/Sources/Model/Mapper/BattleRecordDataDTO+.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/BattleRecordDataDTO+.swift @@ -1,6 +1,6 @@ // // BattleRecordDataDTO+.swift -// Model +// ProfileDomain // import ProfileDomainInterface diff --git a/Projects/Data/Profile/Sources/Model/Mapper/ContentActivityDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/ContentActivityDataDTO+.swift similarity index 98% rename from Projects/Data/Profile/Sources/Model/Mapper/ContentActivityDataDTO+.swift rename to Projects/Domain/ProfileDomain/Sources/Model/Mapper/ContentActivityDataDTO+.swift index e7a6bc80..e6161797 100644 --- a/Projects/Data/Profile/Sources/Model/Mapper/ContentActivityDataDTO+.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/ContentActivityDataDTO+.swift @@ -1,6 +1,6 @@ // // ContentActivityDataDTO+.swift -// Model +// ProfileDomain // import ProfileDomainInterface diff --git a/Projects/Domain/ProfileDomain/Sources/Model/Mapper/CreditHistoryDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/CreditHistoryDataDTO+.swift new file mode 100644 index 00000000..f8538595 --- /dev/null +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/CreditHistoryDataDTO+.swift @@ -0,0 +1,32 @@ +// +// CreditHistoryDataDTO+.swift +// ProfileDomain +// + +import Foundation + +import PickeCoreUtility +import PickeNetworkInterface +import ProfileDomainInterface + +public extension CreditHistoryDataDTO { + func toDomain() -> CreditHistoryPage { + CreditHistoryPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension CreditHistoryItemDTO { + func toDomain() -> CreditHistoryItem { + CreditHistoryItem( + id: id ?? 0, + creditType: creditType ?? "", + amount: amount ?? 0, + referenceId: referenceId, + createdAt: createdAt.flatMap(ServerDateParser.parse) + ) + } +} diff --git a/Projects/Data/Profile/Sources/Model/Mapper/MyPageDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/MyPageDataDTO+.swift similarity index 98% rename from Projects/Data/Profile/Sources/Model/Mapper/MyPageDataDTO+.swift rename to Projects/Domain/ProfileDomain/Sources/Model/Mapper/MyPageDataDTO+.swift index 81895740..eed6c179 100644 --- a/Projects/Data/Profile/Sources/Model/Mapper/MyPageDataDTO+.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/MyPageDataDTO+.swift @@ -1,6 +1,6 @@ // // MyPageDataDTO+.swift -// Model +// ProfileDomain // import ProfileDomainInterface diff --git a/Projects/Data/Profile/Sources/Model/Mapper/NotificationSettingsDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/NotificationSettingsDataDTO+.swift similarity index 96% rename from Projects/Data/Profile/Sources/Model/Mapper/NotificationSettingsDataDTO+.swift rename to Projects/Domain/ProfileDomain/Sources/Model/Mapper/NotificationSettingsDataDTO+.swift index 99684446..96eb28d7 100644 --- a/Projects/Data/Profile/Sources/Model/Mapper/NotificationSettingsDataDTO+.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/NotificationSettingsDataDTO+.swift @@ -1,6 +1,6 @@ // // NotificationSettingsDataDTO+.swift -// Model +// ProfileDomain // import ProfileDomainInterface diff --git a/Projects/Data/Profile/Sources/Model/Mapper/ProfileUpdateDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/ProfileUpdateDataDTO+.swift similarity index 94% rename from Projects/Data/Profile/Sources/Model/Mapper/ProfileUpdateDataDTO+.swift rename to Projects/Domain/ProfileDomain/Sources/Model/Mapper/ProfileUpdateDataDTO+.swift index 299c7143..bed85ce3 100644 --- a/Projects/Data/Profile/Sources/Model/Mapper/ProfileUpdateDataDTO+.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/ProfileUpdateDataDTO+.swift @@ -1,6 +1,6 @@ // // ProfileUpdateDataDTO+.swift -// ProfileData +// ProfileDomain // import Foundation diff --git a/Projects/Data/Profile/Sources/Model/Mapper/RecapDataDTO+.swift b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/RecapDataDTO+.swift similarity index 98% rename from Projects/Data/Profile/Sources/Model/Mapper/RecapDataDTO+.swift rename to Projects/Domain/ProfileDomain/Sources/Model/Mapper/RecapDataDTO+.swift index 92963fce..d346baa7 100644 --- a/Projects/Data/Profile/Sources/Model/Mapper/RecapDataDTO+.swift +++ b/Projects/Domain/ProfileDomain/Sources/Model/Mapper/RecapDataDTO+.swift @@ -1,6 +1,6 @@ // // RecapDataDTO+.swift -// Model +// ProfileDomain // import ProfileDomainInterface diff --git a/Projects/Domain/ProfileDomain/Sources/ProfileLiveDependencies.swift b/Projects/Domain/ProfileDomain/Sources/ProfileLiveDependencies.swift new file mode 100644 index 00000000..31ccdb53 --- /dev/null +++ b/Projects/Domain/ProfileDomain/Sources/ProfileLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// ProfileLiveDependencies.swift +// ProfileDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import ProfileDomainInterface +import ComposableArchitecture + +extension ProfileUseCaseDependency: DependencyKey { + public static var liveValue: ProfileInterface { ProfileUseCaseImpl() } +} + +extension ProfileRepositoryDependency: DependencyKey { + public static var liveValue: ProfileInterface { ProfileRepositoryImpl() } +} diff --git a/Projects/Domain/ProfileDomain/Sources/Repository/ProfileRepositoryImpl.swift b/Projects/Domain/ProfileDomain/Sources/Repository/ProfileRepositoryImpl.swift new file mode 100644 index 00000000..999b0f46 --- /dev/null +++ b/Projects/Domain/ProfileDomain/Sources/Repository/ProfileRepositoryImpl.swift @@ -0,0 +1,142 @@ +// +// ProfileRepositoryImpl.swift +// Repository +// + +import Foundation + +import Dependencies + +import APIEndpoint +import PickeNetwork +import ProfileDomainInterface + +public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func fetchMyPage() async throws -> MyPage { + let data = try await client.send( + ProfileService.mypage, + as: MyPageDataDTO.self + ) + + return data.toDomain() + } + + public func fetchRecap() async throws -> PhilosopherRecap { + // 배틀 5개 미만 사용자는 서버가 data: nil (200) 로 응답 → 잠금 상태. + // 에러로 던지지 않고 빈 recap(totalParticipation 0 → isLocked)으로 반환. + do { + let data = try await client.send( + ProfileService.recap, + as: RecapDataDTO.self + ) + return data.toDomain() + } catch PickeNetworkError.decoding(.dataMissing) { + return .empty + } catch { + throw error + } + } + + public func fetchCreditHistory( + offset: Int?, + size: Int + ) async throws -> CreditHistoryPage { + let data = try await client.send( + ProfileService.creditsHistory( + query: CreditHistoryQueryRequest( + offset: offset, + size: size + ) + ), + as: CreditHistoryDataDTO.self + ) + + return data.toDomain() + } + + public func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage { + let data = try await client.send( + ProfileService.battleRecords( + query: BattleRecordsQueryRequest( + offset: offset, + size: size, + voteSide: voteSide.flatMap { $0 == .unknown ? nil : $0.rawValue } + ) + ), + as: BattleRecordDataDTO.self + ) + + return data.toDomain() + } + + public func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage { + let data = try await client.send( + ProfileService.contentActivities( + query: ContentActivitiesQueryRequest( + offset: offset, + size: size, + activityType: activityType.flatMap(\.rawValue) + ) + ), + as: ContentActivityDataDTO.self + ) + + return data.toDomain() + } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + let data = try await client.send( + ProfileService.notificationSettings, + as: NotificationSettingsDataDTO.self + ) + + return data.toDomain() + } + + public func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings { + let data = try await client.send( + ProfileService.updateNotificationSettings( + body: NotificationSettingsRequest( + newBattleEnabled: settings.newBattleEnabled, + battleResultEnabled: settings.battleResultEnabled, + commentReplyEnabled: settings.commentReplyEnabled, + newCommentEnabled: settings.newCommentEnabled, + contentLikeEnabled: settings.contentLikeEnabled, + marketingEventEnabled: settings.marketingEventEnabled + ) + ), + as: NotificationSettingsDataDTO.self + ) + + return data.toDomain() + } + + public func updateProfile( + nickname: String, + characterType: String + ) async throws -> UpdatedProfile { + let data = try await client.send( + ProfileService.updateProfile( + body: ProfileUpdateRequest( + nickname: nickname, + characterType: characterType + ) + ), + as: ProfileUpdateDataDTO.self + ) + + return data.toDomain() + } +} diff --git a/Projects/Domain/Profile/Sources/UseCase/ProfileUseCase.swift b/Projects/Domain/ProfileDomain/Sources/UseCase/ProfileUseCase.swift similarity index 100% rename from Projects/Domain/Profile/Sources/UseCase/ProfileUseCase.swift rename to Projects/Domain/ProfileDomain/Sources/UseCase/ProfileUseCase.swift diff --git a/Projects/Domain/Profile/Tests/ProfileDomainTests.swift b/Projects/Domain/ProfileDomain/Tests/Sources/ProfileDomainTests.swift similarity index 100% rename from Projects/Domain/Profile/Tests/ProfileDomainTests.swift rename to Projects/Domain/ProfileDomain/Tests/Sources/ProfileDomainTests.swift diff --git a/Projects/Data/Profile/Tests/ProfileRepositoryTests.swift b/Projects/Domain/ProfileDomain/Tests/Sources/ProfileRepositoryTests.swift similarity index 69% rename from Projects/Data/Profile/Tests/ProfileRepositoryTests.swift rename to Projects/Domain/ProfileDomain/Tests/Sources/ProfileRepositoryTests.swift index 63a7e482..a4453488 100644 --- a/Projects/Data/Profile/Tests/ProfileRepositoryTests.swift +++ b/Projects/Domain/ProfileDomain/Tests/Sources/ProfileRepositoryTests.swift @@ -4,10 +4,13 @@ // import Foundation + +import Dependencies import Testing -@testable import ProfileData +@testable import ProfileDomain +import APIEndpoint import ProfileDomainInterface struct ProfileRepositoryTests { @@ -42,9 +45,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchMyPage() @@ -60,23 +65,31 @@ struct ProfileRepositoryTests { #expect(result.tier.currentPoint == 120) } - @Test func fetchMyPage_은_data_가_없으면_backendError_를_던진다() async throws { + @Test func fetchMyPage_은_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "NOT_FOUND", "message": "마이페이지 없음"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("마이페이지 없음")) { + await expectNetworkResponseError( + statusCode: 200, + code: "NOT_FOUND", + message: "마이페이지 없음" + ) { try await repo.fetchMyPage() } } @Test func fetchMyPage_은_네트워크_계층_에러를_그대로_전파한다() async throws { - let repo = ProfileRepositoryImpl( - provider: ThrowingStubNetworkProvider() - ) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + ProfileRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.fetchMyPage() @@ -98,9 +111,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.updateProfile( nickname: "새 닉네임", @@ -113,15 +128,21 @@ struct ProfileRepositoryTests { #expect(result.updatedAt == "2026-07-16T12:00:00Z") } - @Test func updateProfile_은_data_가_없으면_backendError_를_던진다() async throws { + @Test func updateProfile_은_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "INVALID", "message": "프로필 수정 실패"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("프로필 수정 실패")) { + await expectNetworkResponseError( + statusCode: 200, + code: "INVALID", + message: "프로필 수정 실패" + ) { try await repo.updateProfile( nickname: "새 닉네임", characterType: "OWL" @@ -180,9 +201,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchRecap() @@ -197,18 +220,40 @@ struct ProfileRepositoryTests { #expect(result.preferenceReport.favoriteTopics.first?.tagName == "정치") } - @Test func fetchRecap_은_data_가_없으면_에러_대신_empty_잠금_상태를_반환한다() async throws { + @Test func fetchRecap_은_200_data_nil이면_잠금용_empty_recap을_반환한다() async throws { let json = """ {"statusCode": 200, "data": null, "error": null} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchRecap() #expect(result == .empty) #expect(result.preferenceReport.totalParticipation == 0) + #expect(result.preferenceReport.totalParticipation < 5) + } + + @Test func fetchRecap_은_error_봉투이면_네트워크_response_에러를_던진다() async throws { + let json = """ + {"statusCode": 500, "data": null, "error": {"code": "RECAP_FAILED", "message": "리캡 조회 실패"}} + """ + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8), statusCode: 500) + } operation: { + ProfileRepositoryImpl() + } + + await expectNetworkResponseError( + statusCode: 500, + code: "RECAP_FAILED", + message: "리캡 조회 실패" + ) { + try await repo.fetchRecap() + } } // MARK: - fetchCreditHistory @@ -240,9 +285,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchCreditHistory(offset: 0, size: 20) @@ -258,15 +305,21 @@ struct ProfileRepositoryTests { #expect(result.hasNext == true) } - @Test func fetchCreditHistory_는_data_가_없으면_backendError_를_던진다() async throws { + @Test func fetchCreditHistory_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "ERR", "message": "크레딧 내역 없음"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("크레딧 내역 없음")) { + await expectNetworkResponseError( + statusCode: 200, + code: "ERR", + message: "크레딧 내역 없음" + ) { try await repo.fetchCreditHistory(offset: 0, size: 20) } } @@ -295,9 +348,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchBattleRecords(offset: 0, size: 20, voteSide: nil) @@ -312,15 +367,21 @@ struct ProfileRepositoryTests { #expect(result.hasNext == false) } - @Test func fetchBattleRecords_는_data_가_없으면_backendError_를_던진다() async throws { + @Test func fetchBattleRecords_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "ERR", "message": "배틀 기록 없음"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("배틀 기록 없음")) { + await expectNetworkResponseError( + statusCode: 200, + code: "ERR", + message: "배틀 기록 없음" + ) { try await repo.fetchBattleRecords(offset: 0, size: 20, voteSide: .pro) } } @@ -357,9 +418,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchContentActivities(offset: 0, size: 20, activityType: nil) @@ -374,15 +437,21 @@ struct ProfileRepositoryTests { #expect(result.hasNext == false) } - @Test func fetchContentActivities_는_data_가_없으면_backendError_를_던진다() async throws { + @Test func fetchContentActivities_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "ERR", "message": "콘텐츠 활동 없음"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("콘텐츠 활동 없음")) { + await expectNetworkResponseError( + statusCode: 200, + code: "ERR", + message: "콘텐츠 활동 없음" + ) { try await repo.fetchContentActivities(offset: 0, size: 20, activityType: .comment) } } @@ -404,9 +473,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.fetchNotificationSettings() @@ -418,15 +489,21 @@ struct ProfileRepositoryTests { #expect(result.marketingEventEnabled == false) } - @Test func fetchNotificationSettings_는_data_가_없으면_backendError_를_던진다() async throws { + @Test func fetchNotificationSettings_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "ERR", "message": "알림 설정 없음"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("알림 설정 없음")) { + await expectNetworkResponseError( + statusCode: 200, + code: "ERR", + message: "알림 설정 없음" + ) { try await repo.fetchNotificationSettings() } } @@ -448,9 +525,11 @@ struct ProfileRepositoryTests { "error": null } """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } let result = try await repo.updateNotificationSettings(NotificationSettings()) @@ -462,15 +541,21 @@ struct ProfileRepositoryTests { #expect(result.marketingEventEnabled == true) } - @Test func updateNotificationSettings_는_data_가_없으면_backendError_를_던진다() async throws { + @Test func updateNotificationSettings_는_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ {"statusCode": 200, "data": null, "error": {"code": "ERR", "message": "알림 설정 갱신 실패"}} """ - let repo = ProfileRepositoryImpl( - provider: StubNetworkProvider(stubData: Data(json.utf8)) - ) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + ProfileRepositoryImpl() + } - await #expect(throws: ProfileError.backendError("알림 설정 갱신 실패")) { + await expectNetworkResponseError( + statusCode: 200, + code: "ERR", + message: "알림 설정 갱신 실패" + ) { try await repo.updateNotificationSettings(NotificationSettings()) } } diff --git a/Projects/Data/Profile/Tests/ProfileRequestMappingTests.swift b/Projects/Domain/ProfileDomain/Tests/Sources/ProfileRequestMappingTests.swift similarity index 84% rename from Projects/Data/Profile/Tests/ProfileRequestMappingTests.swift rename to Projects/Domain/ProfileDomain/Tests/Sources/ProfileRequestMappingTests.swift index b308254e..fb79a0e1 100644 --- a/Projects/Data/Profile/Tests/ProfileRequestMappingTests.swift +++ b/Projects/Domain/ProfileDomain/Tests/Sources/ProfileRequestMappingTests.swift @@ -6,10 +6,10 @@ import Foundation import Testing -@testable import ProfileData +@testable import ProfileDomain -import NetworkHeader -import Service +import APIEndpoint +@testable import PickeNetwork struct ProfileRequestMappingTests { @Test func mypage_요청은_GET_이며_경로가_api_v1_me_mypage_이다() throws { @@ -142,10 +142,29 @@ struct ProfileRequestMappingTests { #expect(json["characterType"] as? String == "OWL") } - @Test func 모든_요청은_baseHeader_를_포함한다() throws { - let request = try ProfileService.mypage.asURLRequest() - - #expect(request.value(forHTTPHeaderField: "Content-Type") != nil) - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer") == true) + @Test func 모든_요청은_자동_인증_정책을_사용한다() { + let services: [ProfileService] = [ + .mypage, + .recap, + .creditsHistory(query: CreditHistoryQueryRequest(offset: nil, size: 20)), + .battleRecords(query: BattleRecordsQueryRequest(offset: 0, size: 20, voteSide: nil)), + .contentActivities(query: ContentActivitiesQueryRequest(offset: 0, size: 20, activityType: nil)), + .notificationSettings, + .updateNotificationSettings( + body: NotificationSettingsRequest( + newBattleEnabled: true, + battleResultEnabled: true, + commentReplyEnabled: true, + newCommentEnabled: true, + contentLikeEnabled: true, + marketingEventEnabled: true + ) + ), + .updateProfile(body: ProfileUpdateRequest(nickname: "피클러", characterType: "OWL")), + ] + + for service in services { + #expect(service.authorization == .automatic) + } } } diff --git a/Projects/Domain/ProfileDomain/Tests/Sources/TestSupport.swift b/Projects/Domain/ProfileDomain/Tests/Sources/TestSupport.swift new file mode 100644 index 00000000..f61ba8a0 --- /dev/null +++ b/Projects/Domain/ProfileDomain/Tests/Sources/TestSupport.swift @@ -0,0 +1,141 @@ +// +// TestSupport.swift +// ProfileDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} + +func expectNetworkDataMissing(operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("dataMissing 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case .decoding(.dataMissing) = error else { + Issue.record("dataMissing 에러여야 합니다: \(error)") + return + } + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/Search/Project.swift b/Projects/Domain/Search/Project.swift deleted file mode 100644 index ccc395ba..00000000 --- a/Projects/Domain/Search/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .microModule(name: "SearchDomain"), - bundleId: .appBundleID(name: ".SearchDomain"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Home, .interface), - .Domain(implements: .Entity), - .SPM.weaveDI, - .SPM.composableArchitecture, - ], - interfaceDependencies: [ - .Domain(.Home, .interface), - .Domain(implements: .Entity), - .SPM.weaveDI, - .SPM.composableArchitecture, - ] -) diff --git a/Projects/Domain/Search/Testing/Sources/SearchDomainTesting.swift b/Projects/Domain/Search/Testing/Sources/SearchDomainTesting.swift deleted file mode 100644 index 7d69257a..00000000 --- a/Projects/Domain/Search/Testing/Sources/SearchDomainTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import SearchDomainInterface - -public enum SearchDomainTesting {} diff --git a/Projects/Domain/Search/Interface/Sources/DefaultSearchRepositoryImpl.swift b/Projects/Domain/SearchDomain/Interface/Sources/MockSearchRepository.swift similarity index 73% rename from Projects/Domain/Search/Interface/Sources/DefaultSearchRepositoryImpl.swift rename to Projects/Domain/SearchDomain/Interface/Sources/MockSearchRepository.swift index eebd4679..e49d5fb1 100644 --- a/Projects/Domain/Search/Interface/Sources/DefaultSearchRepositoryImpl.swift +++ b/Projects/Domain/SearchDomain/Interface/Sources/MockSearchRepository.swift @@ -1,13 +1,12 @@ // -// DefaultSearchRepositoryImpl.swift +// MockSearchRepository.swift // DomainInterface // -import Entity import Foundation import HomeDomainInterface -public struct DefaultSearchRepositoryImpl: SearchInterface { +public struct MockSearchRepository: SearchInterface { public init() {} public func searchBattles( diff --git a/Projects/Domain/Search/Interface/Sources/SearchInterface.swift b/Projects/Domain/SearchDomain/Interface/Sources/SearchInterface.swift similarity index 57% rename from Projects/Domain/Search/Interface/Sources/SearchInterface.swift rename to Projects/Domain/SearchDomain/Interface/Sources/SearchInterface.swift index a3c89ca5..af61174e 100644 --- a/Projects/Domain/Search/Interface/Sources/SearchInterface.swift +++ b/Projects/Domain/SearchDomain/Interface/Sources/SearchInterface.swift @@ -3,10 +3,9 @@ // DomainInterface // -import Entity import Foundation import HomeDomainInterface -import WeaveDI +import ComposableArchitecture public protocol SearchInterface: Sendable { func searchBattles( @@ -17,16 +16,12 @@ public protocol SearchInterface: Sendable { ) async throws -> ExploreItemPage } -public struct SearchRepositoryDependency: DependencyKey { - public static var liveValue: SearchInterface { - UnifiedDI.resolve(SearchInterface.self) ?? DefaultSearchRepositoryImpl() - } - - public static var testValue: SearchInterface { - UnifiedDI.resolve(SearchInterface.self) ?? DefaultSearchRepositoryImpl() - } +public enum SearchRepositoryDependency: TestDependencyKey { + public static var testValue: SearchInterface { MockSearchRepository() } +} - public static var previewValue: SearchInterface = liveValue +public enum SearchUseCaseDependency: TestDependencyKey { + public static var testValue: SearchInterface { MockSearchRepository() } } public extension DependencyValues { @@ -39,7 +34,7 @@ public extension DependencyValues { // UseCase 소비자용 별칭 — 인터페이스 강제(구현 모듈 import 불필요). pass-through 라 리포지토리 키로 해소. public extension DependencyValues { var searchUseCase: SearchInterface { - get { self[SearchRepositoryDependency.self] } - set { self[SearchRepositoryDependency.self] = newValue } + get { self[SearchUseCaseDependency.self] } + set { self[SearchUseCaseDependency.self] = newValue } } } diff --git a/Projects/Domain/SearchDomain/Project.swift b/Projects/Domain/SearchDomain/Project.swift new file mode 100644 index 00000000..692d5c12 --- /dev/null +++ b/Projects/Domain/SearchDomain/Project.swift @@ -0,0 +1,26 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "SearchDomain", + bundleId: .appBundleID(name: ".SearchDomain"), + product: .framework, + settings: .settings(), + dependencies: [ + .serviceAssembly, + .domain(.home, .interface), + .domain(.battle, .interface), + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .domain(.home, .interface), + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Data/Search/Sources/SearchBattleDataDTO+.swift b/Projects/Domain/SearchDomain/Sources/SearchBattleDataDTO+.swift similarity index 97% rename from Projects/Data/Search/Sources/SearchBattleDataDTO+.swift rename to Projects/Domain/SearchDomain/Sources/SearchBattleDataDTO+.swift index 59db2802..431cccb3 100644 --- a/Projects/Data/Search/Sources/SearchBattleDataDTO+.swift +++ b/Projects/Domain/SearchDomain/Sources/SearchBattleDataDTO+.swift @@ -1,9 +1,8 @@ // // SearchBattleDataDTO+.swift -// Model +// SearchDomain // -import Entity import Foundation import HomeDomainInterface diff --git a/Projects/Data/Search/Sources/SearchBattleDataDTO.swift b/Projects/Domain/SearchDomain/Sources/SearchBattleDataDTO.swift similarity index 80% rename from Projects/Data/Search/Sources/SearchBattleDataDTO.swift rename to Projects/Domain/SearchDomain/Sources/SearchBattleDataDTO.swift index cd83ac1b..53e64ee4 100644 --- a/Projects/Data/Search/Sources/SearchBattleDataDTO.swift +++ b/Projects/Domain/SearchDomain/Sources/SearchBattleDataDTO.swift @@ -1,12 +1,11 @@ // // SearchBattleDataDTO.swift -// Model +// SearchDomain // +import PickeNetworkInterface import Foundation -import CommonDomainInterface -import Model public struct SearchBattlePageDataDTO: Decodable { public let items: [SearchBattleDTO] @@ -29,5 +28,3 @@ public struct SearchBattleTagDTO: Decodable { public let name: String? public let type: String? } - -public typealias SearchBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Domain/SearchDomain/Sources/SearchLiveDependencies.swift b/Projects/Domain/SearchDomain/Sources/SearchLiveDependencies.swift new file mode 100644 index 00000000..34f51845 --- /dev/null +++ b/Projects/Domain/SearchDomain/Sources/SearchLiveDependencies.swift @@ -0,0 +1,17 @@ +// +// SearchLiveDependencies.swift +// SearchDomain +// +// 이 모듈이 소유한 live 구현을 스스로 등록한다. +// + +import SearchDomainInterface +import ComposableArchitecture + +extension SearchUseCaseDependency: DependencyKey { + public static var liveValue: SearchInterface { SearchUseCaseImpl() } +} + +extension SearchRepositoryDependency: DependencyKey { + public static var liveValue: SearchInterface { SearchRepositoryImpl() } +} diff --git a/Projects/Domain/SearchDomain/Sources/SearchRepositoryImpl.swift b/Projects/Domain/SearchDomain/Sources/SearchRepositoryImpl.swift new file mode 100644 index 00000000..9958f159 --- /dev/null +++ b/Projects/Domain/SearchDomain/Sources/SearchRepositoryImpl.swift @@ -0,0 +1,35 @@ +// +// SearchRepositoryImpl.swift +// Repository +// + +import Foundation + +import Dependencies + +import APIEndpoint +import HomeDomainInterface +import PickeNetwork +import SearchDomainInterface + +import BattleDomainInterface + +public final class SearchRepositoryImpl: SearchInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func searchBattles( + category: String?, + sort: String?, + offset: Int?, + size: Int? + ) async throws -> ExploreItemPage { + let data = try await client.send( + SearchService.battles(category: category, sort: sort, offset: offset, size: size), + as: SearchBattlePageDataDTO.self + ) + + return data.toDomain() + } +} diff --git a/Projects/Domain/Search/Sources/SearchUseCase.swift b/Projects/Domain/SearchDomain/Sources/SearchUseCase.swift similarity index 97% rename from Projects/Domain/Search/Sources/SearchUseCase.swift rename to Projects/Domain/SearchDomain/Sources/SearchUseCase.swift index 57acac6d..7bb1457f 100644 --- a/Projects/Domain/Search/Sources/SearchUseCase.swift +++ b/Projects/Domain/SearchDomain/Sources/SearchUseCase.swift @@ -5,7 +5,6 @@ import Foundation -import Entity import HomeDomainInterface import SearchDomainInterface diff --git a/Projects/Data/Search/Tests/SearchRepositoryTests.swift b/Projects/Domain/SearchDomain/Tests/SearchRepositoryTests.swift similarity index 74% rename from Projects/Data/Search/Tests/SearchRepositoryTests.swift rename to Projects/Domain/SearchDomain/Tests/SearchRepositoryTests.swift index bd0aa30d..5e7dc499 100644 --- a/Projects/Data/Search/Tests/SearchRepositoryTests.swift +++ b/Projects/Domain/SearchDomain/Tests/SearchRepositoryTests.swift @@ -5,9 +5,11 @@ import Testing -@testable import SearchData +@testable import SearchDomain -import Entity +import APIEndpoint +import BattleDomainInterface +import Dependencies import HomeDomainInterface struct SearchRepositoryTests { @@ -43,7 +45,11 @@ struct SearchRepositoryTests { } """.data(using: .utf8)! - let repo = SearchRepositoryImpl(provider: StubNetworkProvider(stubData: json)) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: json) + } operation: { + SearchRepositoryImpl() + } let page = try await repo.searchBattles(category: "연애", sort: "popular", offset: 0, size: 20) @@ -83,7 +89,11 @@ struct SearchRepositoryTests { } """.data(using: .utf8)! - let repo = SearchRepositoryImpl(provider: StubNetworkProvider(stubData: json)) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: json) + } operation: { + SearchRepositoryImpl() + } let page = try await repo.searchBattles(category: nil, sort: nil, offset: nil, size: nil) @@ -92,7 +102,7 @@ struct SearchRepositoryTests { #expect(page.hasNext == false) } - @Test func searchBattles_은_data가_nil이면_backendError를_던진다() async throws { + @Test func searchBattles_은_error_봉투이면_네트워크_response_에러를_던진다() async throws { let json = """ { "statusCode": 400, @@ -101,15 +111,27 @@ struct SearchRepositoryTests { } """.data(using: .utf8)! - let repo = SearchRepositoryImpl(provider: StubNetworkProvider(stubData: json)) + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: json) + } operation: { + SearchRepositoryImpl() + } - await #expect(throws: BattleError.backendError("잘못된 검색 조건입니다")) { + await expectNetworkResponseError( + statusCode: 400, + code: "SEARCH_400", + message: "잘못된 검색 조건입니다" + ) { try await repo.searchBattles(category: nil, sort: nil, offset: nil, size: nil) } } @Test func searchBattles_은_네트워크_에러를_전파한다() async throws { - let repo = SearchRepositoryImpl(provider: ThrowingStubNetworkProvider()) + let repo = withDependencies { + $0.networkClient = ThrowingStubNetworkClient() + } operation: { + SearchRepositoryImpl() + } await #expect(throws: (any Error).self) { try await repo.searchBattles(category: nil, sort: nil, offset: nil, size: nil) diff --git a/Projects/Data/Search/Tests/SearchRequestMappingTests.swift b/Projects/Domain/SearchDomain/Tests/SearchRequestMappingTests.swift similarity index 85% rename from Projects/Data/Search/Tests/SearchRequestMappingTests.swift rename to Projects/Domain/SearchDomain/Tests/SearchRequestMappingTests.swift index 1920c8eb..41dc8382 100644 --- a/Projects/Data/Search/Tests/SearchRequestMappingTests.swift +++ b/Projects/Domain/SearchDomain/Tests/SearchRequestMappingTests.swift @@ -5,9 +5,10 @@ import Testing -@testable import SearchData +@testable import SearchDomain -import NetworkHeader +import APIEndpoint +@testable import PickeNetwork struct SearchRequestMappingTests { @Test func battles_요청은_GET_이며_경로가_api_v1_search_battles_이다() throws { @@ -63,10 +64,9 @@ struct SearchRequestMappingTests { #expect(request.httpBody == nil) } - @Test func battles_요청은_baseHeader_를_포함한다() throws { - let request = try SearchService.battles(category: nil, sort: nil, offset: nil, size: nil).asURLRequest() + @Test func battles_요청은_자동_인증_정책을_사용한다() { + let service = SearchService.battles(category: nil, sort: nil, offset: nil, size: nil) - #expect(request.value(forHTTPHeaderField: "Content-Type") != nil) - #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer") == true) + #expect(service.authorization == .automatic) } } diff --git a/Projects/Domain/Search/Tests/Sources/SearchDomainTests.swift b/Projects/Domain/SearchDomain/Tests/Sources/SearchDomainTests.swift similarity index 100% rename from Projects/Domain/Search/Tests/Sources/SearchDomainTests.swift rename to Projects/Domain/SearchDomain/Tests/Sources/SearchDomainTests.swift diff --git a/Projects/Domain/SearchDomain/Tests/TestSupport.swift b/Projects/Domain/SearchDomain/Tests/TestSupport.swift new file mode 100644 index 00000000..6052d4c7 --- /dev/null +++ b/Projects/Domain/SearchDomain/Tests/TestSupport.swift @@ -0,0 +1,127 @@ +// +// TestSupport.swift +// SearchDataTests +// + +import Foundation +import Testing + +import PickeNetwork + +/// 고정 데이터를 서버 응답 봉투(`{ statusCode, data, error }`)로 해석해 돌려주는 스텁 클라이언트. +/// 실클라이언트와 같은 규칙으로 `error` 를 `ResponseError` 로 승격시킨다. +struct StubNetworkClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: Payload? + let error: Failure? + } + + let stubData: Data + var statusCode: Int = 200 + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + try decode(T.self) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: statusCode, data: stubData) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + try decode(R.Response.self) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) {} + + private func decode(_: T.Type) throws(PickeNetworkError) -> T { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: stubData) + } catch { + throw .decoding(.failed(error)) + } + if let failure = envelope.error { + throw .response( + ResponseError( + httpStatus: envelope.statusCode ?? statusCode, + code: failure.code, + message: failure.message + ) + ) + } + guard let data = envelope.data else { + guard let empty = PickeEmptyResponse() as? T else { + throw .decoding(.dataMissing) + } + return empty + } + return data + } +} + +/// 항상 에러를 던지는 스텁 클라이언트. +struct ThrowingStubNetworkClient: PickeNetworkClient { + struct StubError: Error {} + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + throw .transport(.unknown(StubError())) + } + + func send(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + throw .transport(.unknown(StubError())) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .transport(.unknown(StubError())) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .transport(.unknown(StubError())) + } +} + +func expectNetworkResponseError( + statusCode: Int? = nil, + code: String? = nil, + message: String?, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("네트워크 응답 에러가 던져져야 합니다") + } catch let error as PickeNetworkError { + guard case let .response(response) = error else { + Issue.record("response 에러여야 합니다: \(error)") + return + } + if let statusCode { + #expect(response.httpStatus == statusCode) + } + if let code { + #expect(response.code == code) + } + #expect(response.message == message) + } catch { + Issue.record("예상치 못한 에러 타입: \(error)") + } +} diff --git a/Projects/Domain/UseCase/Project.swift b/Projects/Domain/UseCase/Project.swift deleted file mode 100644 index 88693110..00000000 --- a/Projects/Domain/UseCase/Project.swift +++ /dev/null @@ -1,26 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "UseCase"), - bundleId: .appBundleID(name: ".UseCase"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Comment, .interface), - .Domain(.Home, .interface), - .Domain(implements: .DomainInterface), - .Domain(.Profile), - .SPM.composableArchitecture, - .SPM.weaveDI, - .SPM.mixpanel, - .SPM.mixpanelSessionReplay, - .SPM.googleMobileAds, - ], - sources: ["Sources/**"], - hasTests: true -) diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/AdPlacement.swift b/Projects/Domain/UseCase/Sources/Analytics/Events/AdPlacement.swift deleted file mode 100644 index 91a88f03..00000000 --- a/Projects/Domain/UseCase/Sources/Analytics/Events/AdPlacement.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// AdPlacement.swift -// UseCase -// - -import Foundation - -public enum AdPlacement: String, Sendable { - case charge = "충전소" -} diff --git a/Projects/Domain/UseCase/Sources/Exported/ProfileDomainBridge.swift b/Projects/Domain/UseCase/Sources/Exported/ProfileDomainBridge.swift deleted file mode 100644 index 40560934..00000000 --- a/Projects/Domain/UseCase/Sources/Exported/ProfileDomainBridge.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// ProfileDomainBridge.swift -// UseCase -// - -@_exported import ProfileDomain diff --git a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift deleted file mode 100644 index dd4d8954..00000000 --- a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// KeychainManager.swift -// UseCase -// -// Created by Wonji Suh on 1/2/26. -// - -import Foundation - -import ComposableArchitecture -@_exported import DomainInterface -import Security -import WeaveDI - -public final class KeychainManager: KeychainManaging, @unchecked Sendable { - private let service: String - - private enum Key { - static let accessToken = "ACCESS_TOKEN" - static let refreshToken = "REFRESH_TOKEN" - } - - public init(service: String = "io.Picke.co") { - self.service = service - } - - public func save( - accessToken: String, - refreshToken: String - ) { - saveAccessToken(accessToken) - saveRefreshToken(refreshToken) - } - - public func saveAccessToken(_ token: String) { - save(token, for: Key.accessToken) - } - - public func clearAccessToken() { - delete(for: Key.accessToken) - } - - public func saveRefreshToken(_ token: String) { - save(token, for: Key.refreshToken) - } - - public func accessToken() -> String? { - read(for: Key.accessToken) - } - - public func refreshToken() -> String? { - read(for: Key.refreshToken) - } - - public func clear() { - delete(for: Key.accessToken) - delete(for: Key.refreshToken) - } - - private func save( - _ value: String, - for key: String - ) { - let data = Data(value.utf8) - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key, - ] - - let attributes: [CFString: Any] = [ - kSecValueData: data, - ] - - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) - if status == errSecItemNotFound { - var addQuery = query - addQuery[kSecValueData] = data - _ = SecItemAdd(addQuery as CFDictionary, nil) - } - } - - private func read(for key: String) -> String? { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { - return nil - } - return String(data: data, encoding: .utf8) - } - - private func delete(for key: String) { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key, - ] - SecItemDelete(query as CFDictionary) - } -} - -// keychainManager 의존성(프로토콜/DependencyKey/accessor)은 DomainInterface 에 중앙집중. -// 여기서는 구현체 KeychainManager 만 제공하고 DI(DiRegister)로 주입한다. diff --git a/Projects/Domain/UseCase/Tests/Sources/Test.swift b/Projects/Domain/UseCase/Tests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Domain/UseCase/Tests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Feature/Ad/Interface/UseCase/RewardedAdClient.swift b/Projects/Feature/Ad/Interface/UseCase/RewardedAdClient.swift new file mode 100644 index 00000000..4b58a4ed --- /dev/null +++ b/Projects/Feature/Ad/Interface/UseCase/RewardedAdClient.swift @@ -0,0 +1,31 @@ +// +// RewardedAdClient.swift +// Ad +// + +import Foundation + +import ComposableArchitecture + +/// 리워드 광고 표시 계약. +public struct RewardedAdClient: Sendable { + /// 리워드 광고를 로드/표시하고 보상 획득 여부를 반환. + public var showRewardedAd: @Sendable () async -> Bool + + public init(showRewardedAd: @escaping @Sendable () async -> Bool) { + self.showRewardedAd = showRewardedAd + } +} + +/// 테스트/프리뷰 기본값은 인터페이스가 갖는다. `liveValue` 는 구현 모듈이 `DependencyKey` 로 채운다 +extension RewardedAdClient: TestDependencyKey { + public static let testValue = RewardedAdClient(showRewardedAd: { false }) + public static let previewValue = testValue +} + +public extension DependencyValues { + var rewardedAdClient: RewardedAdClient { + get { self[RewardedAdClient.self] } + set { self[RewardedAdClient.self] = newValue } + } +} diff --git a/Projects/Feature/Ad/Project.swift b/Projects/Feature/Ad/Project.swift new file mode 100644 index 00000000..6bb3ffc9 --- /dev/null +++ b/Projects/Feature/Ad/Project.swift @@ -0,0 +1,25 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Ad", + bundleId: .appBundleID(name: ".Ad"), + settings: .settings(), + dependencies: [ + .core(.logger), + .SPM.adFit, + .SPM.googleMobileAds, + .service(.analytics, .interface), + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Shared/AdKit/Sources/AppStartPopupAd.swift b/Projects/Feature/Ad/Sources/AppStartPopupAd.swift similarity index 62% rename from Projects/Shared/AdKit/Sources/AppStartPopupAd.swift rename to Projects/Feature/Ad/Sources/AppStartPopupAd.swift index 64e826d7..7f4f0050 100644 --- a/Projects/Shared/AdKit/Sources/AppStartPopupAd.swift +++ b/Projects/Feature/Ad/Sources/AppStartPopupAd.swift @@ -1,10 +1,10 @@ // // AppStartPopupAd.swift -// AdKit +// Ad // +import PickeCoreLogger import UIKit -import OSLog import AdFitSDK @@ -12,9 +12,12 @@ import AdFitSDK public enum AppStartPopupAd { /// 메인 화면 진입 후 호출할 때마다 팝업을 시도한다. /// 광고 단위가 없거나 로드에 실패하면 아무것도 뜨지 않는다(무해). + /// + /// - Parameter onAdClick: 팝업을 눌렀을 때. AdKit 은 분석 모듈을 의존하지 않으므로 + /// 트래킹은 호출한 화면이 담당한다. @MainActor - public static func presentIfNeeded() { - AppStartPopupAdPresenter.shared.presentIfNeeded() + public static func presentIfNeeded(onAdClick: @escaping () -> Void = {}) { + AppStartPopupAdPresenter.shared.presentIfNeeded(onAdClick: onAdClick) } } @@ -23,35 +26,32 @@ public enum AppStartPopupAd { final class AppStartPopupAdPresenter: NSObject, SuperboardPopUpDelegate { static let shared = AppStartPopupAdPresenter() - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Picke", - category: "AdFit.AppTransition" - ) - private var popUp: SuperboardPopUp? + private var onAdClick: () -> Void = {} override private init() {} - func presentIfNeeded() { + func presentIfNeeded(onAdClick: @escaping () -> Void = {}) { + self.onAdClick = onAdClick guard popUp == nil else { - Self.logger.debug("AdFit 앱 전환 광고 건너뜀: 이미 요청 중") + PickeLogger.debug("AdFit 앱 전환 광고 건너뜀: 이미 요청 중", category: .ui) return } let adUnitId = Bundle.main.object(forInfoDictionaryKey: "ADFIT_APP_TRANSITION") as? String guard let adUnitId, !adUnitId.isEmpty else { - Self.logger.error("AdFit 앱 전환 광고 단위가 Info.plist에 설정되지 않음") + PickeLogger.error("AdFit 앱 전환 광고 단위가 Info.plist에 설정되지 않음", category: .ui) return } guard let rootViewController = Self.topViewController() else { - Self.logger.error("AdFit 앱 전환 광고를 표시할 ViewController를 찾지 못함") + PickeLogger.error("AdFit 앱 전환 광고를 표시할 ViewController를 찾지 못함", category: .ui) return } let popUp = SuperboardPopUp(adUnitId: adUnitId) popUp.delegate = self self.popUp = popUp - Self.logger.info("AdFit 앱 전환 광고 요청 시작") + PickeLogger.info("AdFit 앱 전환 광고 요청 시작", category: .ui) // present 는 로드 성공 시에만 모달을 띄운다. 실패하면 adViewDidFailToReceiveAd 만 호출된다. popUp.present(rootViewController) } @@ -59,29 +59,28 @@ final class AppStartPopupAdPresenter: NSObject, SuperboardPopUpDelegate { // MARK: SuperboardPopUpDelegate func adViewDidReceiveAd() { - Self.logger.info("AdFit 앱 전환 광고 수신 성공") + PickeLogger.info("AdFit 앱 전환 광고 수신 성공", category: .ui) } func adViewDidFailToReceiveAd(error: Error) { - Self.logger.error( - "AdFit 앱 전환 광고 수신 실패: \(error.localizedDescription, privacy: .public)" - ) + PickeLogger.error("AdFit 앱 전환 광고 수신 실패: \(error.localizedDescription)", category: .ui) popUp = nil } func adViewDidClickAd() { - Self.logger.info("AdFit 앱 전환 광고 클릭") + PickeLogger.info("AdFit 앱 전환 광고 클릭", category: .ui) + onAdClick() } func adViewControllerClickClose() { - Self.logger.info("AdFit 앱 전환 광고 닫기") + PickeLogger.info("AdFit 앱 전환 광고 닫기", category: .ui) popUp = nil } /// 현재 정책에서는 "오늘 그만 보기"도 일반 닫기와 동일하게 처리한다. func adViewControllerClickHideForToday() { UserDefaults.standard.removeObject(forKey: "adfit.appTransition.hideUntil") - Self.logger.info("AdFit 앱 전환 광고 오늘 그만 보기: 숨김 기록을 유지하지 않음") + PickeLogger.info("AdFit 앱 전환 광고 오늘 그만 보기: 숨김 기록을 유지하지 않음", category: .ui) popUp = nil } diff --git a/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift b/Projects/Feature/Ad/Sources/UseCase/RewardedAdClient+Live.swift similarity index 65% rename from Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift rename to Projects/Feature/Ad/Sources/UseCase/RewardedAdClient+Live.swift index 0a71b5ab..b71379c9 100644 --- a/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift +++ b/Projects/Feature/Ad/Sources/UseCase/RewardedAdClient+Live.swift @@ -1,39 +1,30 @@ // -// RewardedAdClient.swift -// UseCase +// RewardedAdClient+Live.swift +// Ad // import Foundation +import PickeCoreLogger +import UIKit +import AdInterface +import PickeAnalyticsInterface import ComposableArchitecture -import LogMacro - import GoogleMobileAds -import UIKit - -public struct RewardedAdClient: Sendable { - /// 리워드 광고를 로드/표시하고 보상 획득 여부를 반환. - public var showRewardedAd: @Sendable () async -> Bool - public init(showRewardedAd: @escaping @Sendable () async -> Bool) { - self.showRewardedAd = showRewardedAd - } -} +// MARK: - Live extension RewardedAdClient: DependencyKey { public static let liveValue = RewardedAdClient( - showRewardedAd: { await RewardedAdPresenter().present() } + showRewardedAd: { + @Dependency(\.analyticsUseCase) var analyticsUseCase + // 클로저에 UseCase 통째로 붙잡지 않도록 track 만 떼어 넘긴다(Sendable). + let track = analyticsUseCase.track + return await RewardedAdPresenter( + onClick: { track(.adClick(AdClickData(placement: .charge, format: .rewarded))) } + ).present() + } ) - - public static let testValue = RewardedAdClient(showRewardedAd: { false }) - public static let previewValue = testValue -} - -public extension DependencyValues { - var rewardedAdClient: RewardedAdClient { - get { self[RewardedAdClient.self] } - set { self[RewardedAdClient.self] = newValue } - } } /// 리워드 광고 1회 표시를 책임지는 프레젠터. 표시 종료(보상/닫힘/실패)까지 self 를 유지한다. @@ -52,6 +43,14 @@ private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { private var earnedReward = false private var retainSelf: RewardedAdPresenter? + /// 광고 클릭 시 호출. 트래킹은 호출부(liveValue)가 주입해 프레젠터는 분석 모듈을 모른다. + private let onClick: @Sendable () -> Void + + init(onClick: @escaping @Sendable () -> Void) { + self.onClick = onClick + super.init() + } + func present() async -> Bool { do { let ad = try await RewardedAd.load(with: adUnitID, request: Request()) @@ -59,7 +58,7 @@ private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { ad.fullScreenContentDelegate = self guard let rootViewController = Self.topViewController() else { - Log.error("[RewardedAd] rootViewController 를 찾지 못했습니다") + PickeLogger.error("[RewardedAd] rootViewController 를 찾지 못했습니다", category: .ui) return false } @@ -68,11 +67,11 @@ private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { self.retainSelf = self ad.present(from: rootViewController) { [weak self] in self?.earnedReward = true - Log.debug("[RewardedAd] 보상 획득") + PickeLogger.debug("[RewardedAd] 보상 획득", category: .ui) } } } catch { - Log.error("[RewardedAd] 광고 로드 실패: \(error.localizedDescription)") + PickeLogger.error("[RewardedAd] 광고 로드 실패: \(error.localizedDescription)", category: .ui) return false } } @@ -83,8 +82,13 @@ private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { finish(earnedReward) } + func adDidRecordClick(_: FullScreenPresentingAd) { + PickeLogger.debug("[RewardedAd] 광고 클릭", category: .ui) + onClick() + } + func ad(_: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { - Log.error("[RewardedAd] 광고 표시 실패: \(error.localizedDescription)") + PickeLogger.error("[RewardedAd] 광고 표시 실패: \(error.localizedDescription)", category: .ui) finish(false) } diff --git a/Projects/Feature/Ad/Tests/Sources/AdTests.swift b/Projects/Feature/Ad/Tests/Sources/AdTests.swift new file mode 100644 index 00000000..677960b1 --- /dev/null +++ b/Projects/Feature/Ad/Tests/Sources/AdTests.swift @@ -0,0 +1,14 @@ +// +// AdTests.swift +// AdTests +// + +@testable import Ad +import Testing + +struct AdTests { + @Test + func adServiceExample() { + #expect(true) + } +} diff --git a/Projects/Presentation/Auth/Interface/Sources/AuthInterface.swift b/Projects/Feature/Auth/Interface/Sources/AuthInterface.swift similarity index 100% rename from Projects/Presentation/Auth/Interface/Sources/AuthInterface.swift rename to Projects/Feature/Auth/Interface/Sources/AuthInterface.swift diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift new file mode 100644 index 00000000..a23b4ae5 --- /dev/null +++ b/Projects/Feature/Auth/Project.swift @@ -0,0 +1,25 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Auth", + bundleId: .appBundleID(name: ".Auth"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .SPM.composableArchitecture, + .ui(.designKit), + .ui(.sharedUI), + .service(.analytics, .interface), + .domain(.auth, .interface), + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift b/Projects/Feature/Auth/Sources/Main/Reducer/LoginFeature.swift similarity index 97% rename from Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift rename to Projects/Feature/Auth/Sources/Main/Reducer/LoginFeature.swift index e3539ff2..af3f9bcc 100644 --- a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift +++ b/Projects/Feature/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -10,12 +10,11 @@ import Foundation import AuthInterface import ComposableArchitecture -import Entity -import LogMacro +import PickeCoreLogger import AuthDomainInterface import PickeDesignKit -import UseCase +import PickeAnalyticsInterface @Reducer public struct LoginFeature { @@ -36,7 +35,7 @@ public struct LoginFeature { public init( userSession: UserSession = .empty ) { - _userSession = Shared(wrappedValue: userSession, .inMemory("UserSession")) + _userSession = Shared(wrappedValue: userSession, .userSession) } } @@ -215,7 +214,7 @@ extension LoginFeature { return .send(.delegate(.presentOnboarding)) case let .failure(error): - #logNetwork("로그인 실패", error.localizedDescription) + PickeLogger.error("로그인 실패: \(error.localizedDescription)", category: .auth) let socialType = state.currentSocialType return .run { _ in await MainActor.run { diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift b/Projects/Feature/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift similarity index 100% rename from Projects/Presentation/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift rename to Projects/Feature/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift diff --git a/Projects/Presentation/Auth/Sources/Main/View/Components/SocialCircleButtonView.swift b/Projects/Feature/Auth/Sources/Main/View/Components/SocialCircleButtonView.swift similarity index 99% rename from Projects/Presentation/Auth/Sources/Main/View/Components/SocialCircleButtonView.swift rename to Projects/Feature/Auth/Sources/Main/View/Components/SocialCircleButtonView.swift index 9cd89883..6386d0e0 100644 --- a/Projects/Presentation/Auth/Sources/Main/View/Components/SocialCircleButtonView.swift +++ b/Projects/Feature/Auth/Sources/Main/View/Components/SocialCircleButtonView.swift @@ -8,7 +8,6 @@ import AuthDomainInterface import AuthenticationServices import ComposableArchitecture -import Entity import SwiftUI struct SocialCircleButtonView: View { diff --git a/Projects/Presentation/Auth/Sources/Main/View/Components/TermsAgreementView.swift b/Projects/Feature/Auth/Sources/Main/View/Components/TermsAgreementView.swift similarity index 100% rename from Projects/Presentation/Auth/Sources/Main/View/Components/TermsAgreementView.swift rename to Projects/Feature/Auth/Sources/Main/View/Components/TermsAgreementView.swift diff --git a/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift b/Projects/Feature/Auth/Sources/Main/View/LoginView.swift similarity index 98% rename from Projects/Presentation/Auth/Sources/Main/View/LoginView.swift rename to Projects/Feature/Auth/Sources/Main/View/LoginView.swift index cf04c04c..870be13c 100644 --- a/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift +++ b/Projects/Feature/Auth/Sources/Main/View/LoginView.swift @@ -6,11 +6,11 @@ // import ComposableArchitecture -import Entity import SwiftUI import AuthDomainInterface import PickeDesignKit +import PickeSharedUI public struct LoginView: View { @Bindable var store: StoreOf diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift b/Projects/Feature/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift similarity index 99% rename from Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift rename to Projects/Feature/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift index a31b5814..1089f87c 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift +++ b/Projects/Feature/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift @@ -9,7 +9,6 @@ import ComposableArchitecture import AuthInterface import PickeDesignKit import Foundation -import LogMacro @Reducer public struct OnBoardingFeature { diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift b/Projects/Feature/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift similarity index 100% rename from Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift rename to Projects/Feature/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/View/OnBoardingView.swift b/Projects/Feature/Auth/Sources/OnBoarding/View/OnBoardingView.swift similarity index 100% rename from Projects/Presentation/Auth/Sources/OnBoarding/View/OnBoardingView.swift rename to Projects/Feature/Auth/Sources/OnBoarding/View/OnBoardingView.swift diff --git a/Projects/Presentation/Auth/Tests/Sources/AuthTests.swift b/Projects/Feature/Auth/Tests/Sources/AuthTests.swift similarity index 84% rename from Projects/Presentation/Auth/Tests/Sources/AuthTests.swift rename to Projects/Feature/Auth/Tests/Sources/AuthTests.swift index f664af61..8e192788 100644 --- a/Projects/Presentation/Auth/Tests/Sources/AuthTests.swift +++ b/Projects/Feature/Auth/Tests/Sources/AuthTests.swift @@ -1,6 +1,6 @@ // // AuthTests.swift -// Presentation.AuthTests +// Feature.AuthTests // // Created by Roy on 2026-05-09. // @@ -18,7 +18,6 @@ struct AuthTests { @Test func authLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Presentation/Battle/Interface/Sources/BattleInterface.swift b/Projects/Feature/Battle/Interface/Sources/BattleInterface.swift similarity index 100% rename from Projects/Presentation/Battle/Interface/Sources/BattleInterface.swift rename to Projects/Feature/Battle/Interface/Sources/BattleInterface.swift diff --git a/Projects/Feature/Battle/Project.swift b/Projects/Feature/Battle/Project.swift new file mode 100644 index 00000000..0579efd5 --- /dev/null +++ b/Projects/Feature/Battle/Project.swift @@ -0,0 +1,26 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Battle", + bundleId: .appBundleID(name: ".Battle"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .SPM.composableArchitecture, + .ui(.designKit), + .ui(.sharedUI), + .core(.coreUtility), + .service(.analytics, .interface), + .domain(.battle, .interface), + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift b/Projects/Feature/Battle/Sources/Main/Model/DailyBattle.swift similarity index 98% rename from Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift rename to Projects/Feature/Battle/Sources/Main/Model/DailyBattle.swift index 88da05d2..48cfb06e 100644 --- a/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift +++ b/Projects/Feature/Battle/Sources/Main/Model/DailyBattle.swift @@ -5,9 +5,8 @@ import Foundation -import Entity import BattleDomainInterface -import Utill +import PickeCoreUtility public struct DailyBattle: Equatable, Identifiable { public var battleId: Int diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Feature/Battle/Sources/Main/Reducer/BattleFeature.swift similarity index 92% rename from Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift rename to Projects/Feature/Battle/Sources/Main/Reducer/BattleFeature.swift index 34e9f134..ee4d863d 100644 --- a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift +++ b/Projects/Feature/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -4,12 +4,12 @@ // import Foundation +import PickeCoreLogger -import ComposableArchitecture -import Entity import BattleDomainInterface -import LogMacro -import UseCase +import ComposableArchitecture +import PickeAnalyticsInterface +import PickeCoreUtility @Reducer public struct BattleFeature { @@ -17,7 +17,13 @@ public struct BattleFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded /// 오늘의 배틀 목록 — 세로 스크롤로 다음 배틀 노출. 비어있으면 "없음". public var battles: [DailyBattle] = [] /// 배틀별 선택한 옵션 (battleId → optionId). 미선택 시 "배틀 입장하기" 비활성. @@ -158,7 +164,7 @@ extension BattleFeature { ) -> Effect { switch action { case .fetchRequested: - state.isLoading = true + state.viewState = .loading return .run { [useCase = battleUseCase] send in let result = await Result { try await useCase.fetchTodayBattles() @@ -181,13 +187,13 @@ extension BattleFeature { ) -> Effect { switch action { case let .todayResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(page): state.battles = page.items.map(DailyBattle.from) case let .failure(error): state.battles = [] - Log.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)") + PickeLogger.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)", category: .ui) } return .none diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Feature/Battle/Sources/Main/View/BattleView.swift similarity index 98% rename from Projects/Presentation/Battle/Sources/Main/View/BattleView.swift rename to Projects/Feature/Battle/Sources/Main/View/BattleView.swift index c8d1f089..ae1f59f2 100644 --- a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift +++ b/Projects/Feature/Battle/Sources/Main/View/BattleView.swift @@ -6,8 +6,7 @@ import SwiftUI import ComposableArchitecture -import Entity -import Kingfisher +import PickeSharedUI import PickeDesignKit @ViewAction(for: BattleFeature.self) @@ -34,7 +33,7 @@ public struct BattleView: View { ZStack { Color.neutral900.ignoresSafeArea() - if store.isLoading { + if store.viewState == .loading { BattleSkeletonView() } else if store.battles.isEmpty { emptyState() @@ -120,10 +119,7 @@ private extension BattleView { func backgroundImage(_ imageURL: String?) -> some View { ZStack { if let imageURL, let url = URL(string: imageURL) { - KFImage(url) - .placeholder { Color.neutral800 } - .resizable() - .scaledToFill() + PickeRemoteImage(url: url) { Color.neutral800 } } else { Color.neutral800 } diff --git a/Projects/Presentation/Battle/Sources/Main/View/Components/BattlePagingBar.swift b/Projects/Feature/Battle/Sources/Main/View/Components/BattlePagingBar.swift similarity index 100% rename from Projects/Presentation/Battle/Sources/Main/View/Components/BattlePagingBar.swift rename to Projects/Feature/Battle/Sources/Main/View/Components/BattlePagingBar.swift diff --git a/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift b/Projects/Feature/Battle/Sources/Main/View/Components/BattleSkeletonView.swift similarity index 58% rename from Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift rename to Projects/Feature/Battle/Sources/Main/View/Components/BattleSkeletonView.swift index e1857796..0ec78c7a 100644 --- a/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift +++ b/Projects/Feature/Battle/Sources/Main/View/Components/BattleSkeletonView.swift @@ -44,32 +44,16 @@ struct BattleSkeletonView: View { /// 다크 배경용 skeleton 블록 (어두운 base + 옅은 흰색 shimmer). private struct DarkSkeletonBlock: View { - let cornerRadius: CGFloat - - @State private var phase: CGFloat = -1 + private static let baseColor = Color.white.opacity(0.08) + private static let shimmerColor = Color.white.opacity(0.20) - private let baseColor = Color.white.opacity(0.08) - private let shimmerColor = Color.white.opacity(0.20) + let cornerRadius: CGFloat var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(baseColor) - .overlay { - LinearGradient( - stops: [ - .init(color: shimmerColor.opacity(0), location: 0), - .init(color: shimmerColor, location: 0.5), - .init(color: shimmerColor.opacity(0), location: 1), - ], - startPoint: UnitPoint(x: phase, y: 0.5), - endPoint: UnitPoint(x: phase + 1, y: 0.5) - ) - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { - phase = 2 - } - } + SkeletonView( + .round(cornerRadius: cornerRadius), + base: Self.baseColor, + highlight: Self.shimmerColor + ) } } diff --git a/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift b/Projects/Feature/Battle/Tests/Sources/BattleTests.swift similarity index 84% rename from Projects/Presentation/Battle/Tests/Sources/BattleTests.swift rename to Projects/Feature/Battle/Tests/Sources/BattleTests.swift index 4a2f4c62..51a5a709 100644 --- a/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift +++ b/Projects/Feature/Battle/Tests/Sources/BattleTests.swift @@ -1,6 +1,6 @@ // // BattleTests.swift -// Presentation.BattleTests +// Feature.BattleTests // // Created by Roy on 2026-06-05. // @@ -18,7 +18,6 @@ struct BattleTests { @Test func battleLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Presentation/Chat/Interface/Sources/ChatInterface.swift b/Projects/Feature/Chat/Interface/Sources/ChatInterface.swift similarity index 100% rename from Projects/Presentation/Chat/Interface/Sources/ChatInterface.swift rename to Projects/Feature/Chat/Interface/Sources/ChatInterface.swift diff --git a/Projects/Feature/Chat/Project.swift b/Projects/Feature/Chat/Project.swift new file mode 100644 index 00000000..c37b240a --- /dev/null +++ b/Projects/Feature/Chat/Project.swift @@ -0,0 +1,34 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Chat", + bundleId: .appBundleID(name: ".Chat"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .SPM.tcaFlow, + .SPM.composableArchitecture, + .service(.audioPlayer, .interface), + .domain(.perspective, .interface), + .ui(.designKit), + .ui(.sharedUI), + .core(.coreUtility), + .service(.analytics, .interface), + .domain(.battle, .interface), + .domain(.home, .interface), + .core(.network), + + .domain(.comment, .interface), + .feature(.featureSharedUI, .implementation), + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift similarity index 98% rename from Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift rename to Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 05968c6a..da927dac 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -6,17 +6,17 @@ // import Foundation +import PickeCoreLogger import BattleDomainInterface import ChatInterface import ComposableArchitecture -import DomainInterface -import Entity import HomeDomainInterface -import LogMacro import PickeDesignKit -import UseCase -import Utill +import PickeSharedUI +import PickeCoreUtility +import PickeAnalyticsInterface +import AudioPlayerServiceInterface @Reducer public struct ChatRoomFeature { @@ -518,7 +518,7 @@ extension ChatRoomFeature { return .none case let .failure(error): state.scenarioLoadFailed = true - Log.error("[ChatRoomFeature] fetchScenario failed: \(error) — \(error.localizedDescription)") + PickeLogger.error("[ChatRoomFeature] fetchScenario failed: \(error) — \(error.localizedDescription)", category: .ui) return .none } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Feature/Chat/Sources/ChatRoom/View/ChatRoomView.swift similarity index 97% rename from Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift rename to Projects/Feature/Chat/Sources/ChatRoom/View/ChatRoomView.swift index ed941bae..422b91f0 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Feature/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -8,10 +8,9 @@ import SwiftUI import ComposableArchitecture -import Entity import HomeDomainInterface -import Kingfisher import PickeDesignKit +import PickeSharedUI @ViewAction(for: ChatRoomFeature.self) public struct ChatRoomView: View { @@ -278,15 +277,14 @@ extension ChatRoomView { @ViewBuilder private func avatar(_ speaker: ChatSpeaker) -> some View { - KFImage(URL(string: speaker.imageURL ?? "")) - .placeholder { - SkeletonView(cornerRadius: Metric.avatarSize / 2) - } - .resizable() - .scaledToFit() - .frame(width: Metric.avatarImageWidth, height: Metric.avatarImageHeight) - .frame(width: Metric.avatarSize, height: Metric.avatarSize) - .background(.beige600, in: Circle()) + PickeRemoteImage( + url: speaker.imageURL ?? "", + shape: .round(cornerRadius: Metric.avatarSize / 2) + ) + .content(.fit) + .frame(width: Metric.avatarImageWidth, height: Metric.avatarImageHeight) + .frame(width: Metric.avatarSize, height: Metric.avatarSize) + .background(.beige600, in: Circle()) } @ViewBuilder diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift b/Projects/Feature/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift similarity index 75% rename from Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift rename to Projects/Feature/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift index bb9808c1..3ef6dc1e 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift +++ b/Projects/Feature/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift @@ -24,10 +24,10 @@ private extension ChatRoomSkeletonView { @ViewBuilder func navigationBarSkeleton() -> some View { HStack(spacing: 12) { - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 20, height: 24) Spacer() - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 24, height: 24) } .padding(.horizontal, 20) @@ -54,16 +54,16 @@ private extension ChatRoomSkeletonView { @ViewBuilder func leftGroup() -> some View { HStack(alignment: .top, spacing: 8) { - SkeletonView(cornerRadius: 20) + SkeletonView(.round(cornerRadius: 20)) .frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 8) { - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 37, height: 20) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 256, height: 54) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 256, height: 36) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 256, height: 36) } Spacer(minLength: 0) @@ -75,16 +75,16 @@ private extension ChatRoomSkeletonView { HStack(alignment: .top, spacing: 8) { Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 8) { - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 49, height: 20) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 256, height: 54) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 256, height: 36) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 256, height: 54) } - SkeletonView(cornerRadius: 20) + SkeletonView(.round(cornerRadius: 20)) .frame(width: 40, height: 40) } } @@ -96,15 +96,15 @@ private extension ChatRoomSkeletonView { @ViewBuilder func playerBarSkeleton() -> some View { VStack(spacing: 16) { - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(height: 18) HStack(alignment: .top, spacing: 32) { - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 24, height: 55) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 55, height: 55) - SkeletonView(cornerRadius: 6) + SkeletonView(.round(cornerRadius: 6)) .frame(width: 24, height: 55) } } diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift similarity index 96% rename from Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift rename to Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift index 4637c190..446e1f89 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -4,17 +4,15 @@ // import Foundation +import PickeCoreLogger import BattleDomainInterface import CommentDomainInterface -import CommonDomainInterface import ComposableArchitecture -import DomainInterface -import Entity -import LogMacro import PerspectiveDomainInterface import PickeDesignKit -import UseCase +import PickeSharedUI +import PickeAnalyticsInterface @Reducer public struct CommentFeature { @@ -512,7 +510,7 @@ extension CommentFeature { } } case let .failure(error): - Log.error("[CommentFeature] fetchBattle failed: \(error.localizedDescription)") + PickeLogger.error("[CommentFeature] fetchBattle failed: \(error.localizedDescription)", category: .ui) } return .none @@ -528,7 +526,7 @@ extension CommentFeature { } } case let .failure(error): - Log.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)") + PickeLogger.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)", category: .ui) } return .none @@ -538,7 +536,7 @@ extension CommentFeature { case let .success(stats): state.voteSummary = makeSummary(from: stats, fallback: state.voteSummary) case let .failure(error): - Log.error("[CommentFeature] fetchVoteStats failed: \(error.localizedDescription)") + PickeLogger.error("[CommentFeature] fetchVoteStats failed: \(error.localizedDescription)", category: .ui) } return .none @@ -567,7 +565,7 @@ extension CommentFeature { // (이전엔 댓글마다 GET /perspectives/{id}/likes 로 덮어써 수치가 로드 후 바뀌는 문제가 있었음) return .none case let .failure(error): - Log.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)") + PickeLogger.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)", category: .ui) } return .none @@ -579,7 +577,7 @@ extension CommentFeature { state.comments[index].isLiked = payload.isLiked } case let .failure(error): - Log.error("[CommentFeature] toggleLike failed: \(error.localizedDescription)") + PickeLogger.error("[CommentFeature] toggleLike failed: \(error.localizedDescription)", category: .ui) } return .none @@ -599,7 +597,7 @@ extension CommentFeature { } return .send(.async(.fetchPerspectives(reset: true))) case let .failure(error): - Log.error("[CommentFeature] createComment failed: \(error.localizedDescription)") + PickeLogger.error("[CommentFeature] createComment failed: \(error.localizedDescription)", category: .ui) return .none } diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Feature/Chat/Sources/Comment/View/CommentView.swift similarity index 99% rename from Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift rename to Projects/Feature/Chat/Sources/Comment/View/CommentView.swift index 8888f91d..40e2a60d 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Feature/Chat/Sources/Comment/View/CommentView.swift @@ -7,9 +7,9 @@ import SwiftUI import CommentDomainInterface import ComposableArchitecture -import Entity import PickeDesignKit -import Utill +import PickeSharedUI +import PickeCoreUtility @ViewAction(for: CommentFeature.self) public struct CommentView: View { diff --git a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift b/Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift similarity index 64% rename from Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift rename to Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift index a63f168b..62138dff 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift +++ b/Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift @@ -22,16 +22,16 @@ struct CommentSkeletonView: View { private func card() -> some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { - SkeletonView(cornerRadius: 14) + SkeletonView(.round(cornerRadius: 14)) .frame(width: 28, height: 28) VStack(alignment: .leading, spacing: 4) { - SkeletonView(cornerRadius: 4).frame(width: 80, height: 12) - SkeletonView(cornerRadius: 4).frame(width: 48, height: 10) + SkeletonView(.round(cornerRadius: 4)).frame(width: 80, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 48, height: 10) } Spacer() } - SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) - SkeletonView(cornerRadius: 4).frame(width: 200, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 200, height: 12) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift similarity index 94% rename from Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift rename to Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index d1f245a1..ac24146c 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -6,16 +6,15 @@ // import Foundation +import PickeCoreLogger import CommentDomainInterface -import CommonDomainInterface +import BattleDomainInterface import ComposableArchitecture -import DomainInterface -import Entity -import LogMacro import PerspectiveDomainInterface import PickeDesignKit -import UseCase +import PickeSharedUI +import PickeAnalyticsInterface @Reducer public struct CommentReplyFeature { @@ -509,7 +508,7 @@ extension CommentReplyFeature { case let .success(perspective): state.parentComment = CommentItem(item: perspective, order: 0) case let .failure(error): - Log.error("[CommentReplyFeature] fetchParent failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] fetchParent failed: \(error.localizedDescription)", category: .ui) } return .none @@ -524,7 +523,7 @@ extension CommentReplyFeature { state.nextCursor = page.nextCursor state.hasNext = page.hasNext case let .failure(error): - Log.error("[CommentReplyFeature] fetchReplies failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] fetchReplies failed: \(error.localizedDescription)", category: .ui) } return .none @@ -533,7 +532,7 @@ extension CommentReplyFeature { case .success: return .send(.async(.fetchReplies(reset: true))) case let .failure(error): - Log.error("[CommentReplyFeature] createReply failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] createReply failed: \(error.localizedDescription)", category: .ui) return .none } @@ -543,7 +542,7 @@ extension CommentReplyFeature { // 수정 후 리스트 reset 갱신 → 스켈레톤 노출 return .send(.async(.fetchReplies(reset: true))) case let .failure(error): - Log.error("[CommentReplyFeature] updateReply failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] updateReply failed: \(error.localizedDescription)", category: .ui) return .none } @@ -553,7 +552,7 @@ extension CommentReplyFeature { state.replies.removeAll { $0.commentId == commentId } if state.parentComment.replyCount > 0 { state.parentComment.replyCount -= 1 } case let .failure(error): - Log.error("[CommentReplyFeature] deleteReply failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] deleteReply failed: \(error.localizedDescription)", category: .ui) } return .none @@ -563,7 +562,7 @@ extension CommentReplyFeature { state.parentComment.likeCount = payload.likeCount state.parentComment.isLiked = payload.isLiked case let .failure(error): - Log.error("[CommentReplyFeature] toggleParentLike failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] toggleParentLike failed: \(error.localizedDescription)", category: .ui) } return .none @@ -575,7 +574,7 @@ extension CommentReplyFeature { state.replies[index].isLiked = payload.isLiked } case let .failure(error): - Log.error("[CommentReplyFeature] toggleReplyLike failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] toggleReplyLike failed: \(error.localizedDescription)", category: .ui) } return .none @@ -584,7 +583,7 @@ extension CommentReplyFeature { case let .success(payload): state.parentComment.likeCount = payload.likeCount case let .failure(error): - Log.error("[CommentReplyFeature] fetchParentLikes failed: \(error.localizedDescription)") + PickeLogger.error("[CommentReplyFeature] fetchParentLikes failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift similarity index 99% rename from Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift rename to Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift index 098bf303..c71f47b7 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -7,9 +7,9 @@ import SwiftUI import CommentDomainInterface import ComposableArchitecture -import Entity import PickeDesignKit -import Utill +import PickeSharedUI +import PickeCoreUtility @ViewAction(for: CommentReplyFeature.self) public struct CommentReplyView: View { diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift b/Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift similarity index 72% rename from Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift rename to Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift index bdcb7d6f..2193f265 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift @@ -18,7 +18,7 @@ struct CommentReplySkeletonView: View { .bottomDivider(.beige600) VStack(alignment: .leading, spacing: 8) { - SkeletonView(cornerRadius: 4) + SkeletonView(.round(cornerRadius: 4)) .frame(width: 64, height: 14) .padding(.horizontal, 12) .padding(.top, 12) @@ -44,19 +44,19 @@ struct CommentReplySkeletonView: View { private func card() -> some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { - SkeletonView(cornerRadius: 18) + SkeletonView(.round(cornerRadius: 18)) .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 4) { - SkeletonView(cornerRadius: 4).frame(width: 90, height: 14) - SkeletonView(cornerRadius: 4).frame(width: 56, height: 16) + SkeletonView(.round(cornerRadius: 4)).frame(width: 90, height: 14) + SkeletonView(.round(cornerRadius: 4)).frame(width: 56, height: 16) } Spacer() } - SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) - SkeletonView(cornerRadius: 4).frame(width: 220, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 220, height: 12) HStack { Spacer() - SkeletonView(cornerRadius: 4).frame(width: 44, height: 14) + SkeletonView(.round(cornerRadius: 4)).frame(width: 44, height: 14) } } .padding(12) diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Feature/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift similarity index 97% rename from Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift rename to Projects/Feature/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 154e4e2e..31a3d7b2 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Feature/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -4,15 +4,15 @@ // import Foundation +import PickeCoreLogger import ChatInterface import CommentDomainInterface import ComposableArchitecture -import DomainInterface -import LogMacro import PickeDesignKit -import Shared +import PickeCoreUtility import TCAFlow +import AudioPlayerServiceInterface @FlowCoordinator(screen: "ChatScreen", navigation: true) public struct ChatCoordinator { @@ -143,7 +143,7 @@ extension ChatCoordinator { case let .routeAction(_, action: .comment(.delegate(.openReply(comment)))): // perspectiveId 가 없는 관점은 대댓글 식별이 불가능하므로 진입하지 않는다. guard let perspectiveId = comment.perspectiveId else { - Log.error("[ChatCoordinator] openReply 무시 — perspectiveId 가 nil 인 CommentItem") + PickeLogger.error("[ChatCoordinator] openReply 무시 — perspectiveId 가 nil 인 CommentItem", category: .navigation) return .none } state.routes.push( diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Feature/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift similarity index 100% rename from Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift rename to Projects/Feature/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift diff --git a/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift b/Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift similarity index 83% rename from Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift rename to Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift index 1974feb7..7c7b895d 100644 --- a/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift +++ b/Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift @@ -4,13 +4,11 @@ // import Foundation +import PickeCoreLogger -import ComposableArchitecture -import DomainInterface -import Entity import BattleDomainInterface -import LogMacro -import UseCase +import ComposableArchitecture +import PickeAnalyticsInterface @Reducer public struct CurationFeature { @@ -19,7 +17,13 @@ public struct CurationFeature { @ObservableState public struct State: Equatable { public var battleId: Int - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var battles: [RecommendedBattle] = [] public init(battleId: Int = 0) { @@ -40,6 +44,7 @@ public struct CurationFeature { case backButtonTapped case closeButtonTapped case battleTapped(battleId: Int) + case adNativeClicked } public enum AsyncAction: Equatable { @@ -102,6 +107,10 @@ extension CurationFeature { case let .battleTapped(battleId): analyticsUseCase.track(.uiAction(action: .curationBattle, screen: .curation)) return .send(.delegate(.openBattle(battleId: battleId))) + + case .adNativeClicked: + analyticsUseCase.track(.adClick(AdClickData(placement: .curation, format: .native, unit: "ADFIT_NATIVE_2_1"))) + return .none } } @@ -111,7 +120,7 @@ extension CurationFeature { ) -> Effect { switch action { case .fetchRecommendations: - state.isLoading = true + state.viewState = .loading let battleId = state.battleId return .run { [useCase = battleUseCase] send in let result = await Result { @@ -130,12 +139,12 @@ extension CurationFeature { ) -> Effect { switch action { case let .recommendationsResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(page): state.battles = page.items case let .failure(error): - Log.error("[CurationFeature] recommendations failed: \(error.localizedDescription)") + PickeLogger.error("[CurationFeature] recommendations failed: \(error.localizedDescription)", category: .ui) state.battles = [] } return .none diff --git a/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift b/Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift similarity index 57% rename from Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift rename to Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift index d31185f5..ad9250a2 100644 --- a/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift +++ b/Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift @@ -23,19 +23,19 @@ struct CurationSkeletonView: View { VStack(alignment: .leading, spacing: 16) { // meta HStack(spacing: 10) { - SkeletonView(cornerRadius: .radiusDefault).frame(width: 44, height: 18) + SkeletonView(.round(cornerRadius: .radiusDefault)).frame(width: 44, height: 18) Spacer() - SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) - SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 36, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 36, height: 12) } VStack(alignment: .leading, spacing: 4) { - SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 14) - SkeletonView(cornerRadius: 4).frame(width: 220, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 14) + SkeletonView(.round(cornerRadius: 4)).frame(width: 220, height: 12) } // versus HStack(spacing: 8) { optionPlaceholder() - SkeletonView(cornerRadius: 12).frame(width: 24, height: 24) + SkeletonView(.round(cornerRadius: 12)).frame(width: 24, height: 24) optionPlaceholder() } } @@ -47,10 +47,10 @@ struct CurationSkeletonView: View { @ViewBuilder private func optionPlaceholder() -> some View { HStack(spacing: 4) { - SkeletonView(cornerRadius: 20).frame(width: 40, height: 40) + SkeletonView(.round(cornerRadius: 20)).frame(width: 40, height: 40) VStack(spacing: 2) { - SkeletonView(cornerRadius: 4).frame(width: 44, height: 11) - SkeletonView(cornerRadius: 4).frame(width: 30, height: 10) + SkeletonView(.round(cornerRadius: 4)).frame(width: 44, height: 11) + SkeletonView(.round(cornerRadius: 4)).frame(width: 30, height: 10) } } .frame(maxWidth: .infinity) diff --git a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift b/Projects/Feature/Chat/Sources/Curation/View/CurationView.swift similarity index 96% rename from Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift rename to Projects/Feature/Chat/Sources/Curation/View/CurationView.swift index 19fcd805..d0daaa94 100644 --- a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift +++ b/Projects/Feature/Chat/Sources/Curation/View/CurationView.swift @@ -5,12 +5,12 @@ import SwiftUI -import AdKit import BattleDomainInterface import ComposableArchitecture -import Entity import PickeDesignKit -import Utill +import PickeSharedUI +import PickeCoreUtility +import FeatureSharedUI @ViewAction(for: CurationFeature.self) public struct CurationView: View { @@ -29,10 +29,11 @@ public struct CurationView: View { // 광고가 없으면 AdFitNativeAdView 가 스스로 자리를 접어 높이 0 이 된다. AdFitNativeAdView( unit: .wide, - insets: EdgeInsets(top: 0, leading: 0, bottom: 4, trailing: 0) + insets: EdgeInsets(top: 0, leading: 0, bottom: 4, trailing: 0), + onAdClick: { send(.adNativeClicked) } ) - if store.isLoading, store.battles.isEmpty { + if store.viewState == .loading, store.battles.isEmpty { CurationSkeletonView() } else if store.battles.isEmpty { emptyState() diff --git a/Projects/Feature/Chat/Sources/Vote/Model/ShareSnapshotRequest.swift b/Projects/Feature/Chat/Sources/Vote/Model/ShareSnapshotRequest.swift new file mode 100644 index 00000000..4cb32012 --- /dev/null +++ b/Projects/Feature/Chat/Sources/Vote/Model/ShareSnapshotRequest.swift @@ -0,0 +1,19 @@ +// +// ShareSnapshotRequest.swift +// Chat +// + +import Foundation + +public struct ShareSnapshotRequest: Equatable, Identifiable { + public let id: UUID + public let avatarImageData: [Int: Data] + + public init( + id: UUID = UUID(), + avatarImageData: [Int: Data] + ) { + self.id = id + self.avatarImageData = avatarImageData + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Feature/Chat/Sources/Vote/Reducer/PreVoteFeature.swift similarity index 92% rename from Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift rename to Projects/Feature/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 8462bdd5..604bb11d 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Feature/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -6,16 +6,15 @@ // import Foundation +import PickeCoreLogger import BattleDomainInterface -import CommonDomainInterface import ComposableArchitecture -import DomainInterface -import Entity -import LogMacro import PerspectiveDomainInterface +import PickeAnalyticsInterface +import PickeCoreUtility import PickeDesignKit -import UseCase +import PickeSharedUI @Reducer public struct PreVoteFeature { @@ -26,26 +25,18 @@ public struct PreVoteFeature { case post } - /// 공유 카드 렌더 요청 — 아바타 선로드가 끝나면 채워지고, 뷰가 이 값으로 카드를 그린다. - public struct ShareSnapshotRequest: Equatable, Identifiable { - public let id: UUID - public let avatarImageData: [Int: Data] - - public init( - id: UUID = UUID(), - avatarImageData: [Int: Data] - ) { - self.id = id - self.avatarImageData = avatarImageData - } - } - @ObservableState public struct State: Equatable { public var battle: PreVoteBattle? public var battleDetail: BattleDetail? public var selectedOptionId: Int? - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded /// 기참여(관점 직행) 여부 확인 중 — 완료 전까지 사전투표 UI 대신 스켈레톤을 유지한다. public var isCheckingParticipation: Bool = false /// 배틀 상세(사전투표) 로드(디코딩/네트워크) 실패 여부. true 면 무한 스켈레톤 대신 오류+재시도 UI 를 노출한다. @@ -186,7 +177,7 @@ extension PreVoteFeature { case .onAppear: analyticsUseCase.track(.screenView(screen: .prevote, referrer: nil)) var effects: [Effect] = [] - if state.battleDetail == nil, state.battle == nil, !state.isLoading { + if state.battleDetail == nil, state.battle == nil, state.viewState != .loading { effects.append(.send(.async(.fetchBattleDetail))) } // 사전(pre) 진입 시에만 내 참여(perspective) 여부를 조회한다. @@ -271,7 +262,7 @@ extension PreVoteFeature { ) -> Effect { switch action { case .fetchBattleDetail: - state.isLoading = true + state.viewState = .loading state.detailLoadFailed = false let battleId = state.battleId return .run { [repository = battleUseCase] send in @@ -309,12 +300,12 @@ extension PreVoteFeature { guard let battle = state.battle else { return .send(.async(.prepareShare(shareContent(state: state, snapshot: nil)))) } - + let avatars = [battle.leftOption, battle.rightOption].map { ($0.optionId, $0.imageURL) } - + return .run { [useCase = shareUseCase] send in var imageData: [Int: Data] = [:] - + for (optionId, imageURL) in avatars { imageData[optionId] = await useCase.loadImageData(imageURL) } @@ -355,7 +346,7 @@ extension PreVoteFeature { ) -> Effect { switch action { case let .battleDetailResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(detail): state.battleDetail = detail @@ -363,7 +354,10 @@ extension PreVoteFeature { state.detailLoadFailed = false case let .failure(error): state.detailLoadFailed = true - Log.error("[PreVoteFeature] fetchBattle failed: \(error) — \(error.localizedDescription)") + PickeLogger.error( + "[PreVoteFeature] fetchBattle failed: \(error) — \(error.localizedDescription)", + category: .ui + ) } return .none @@ -377,7 +371,7 @@ extension PreVoteFeature { return .send(.delegate(.alreadyFinalVoted(battleId: state.battleId))) } case let .failure(error): - Log.error("[PreVoteFeature] fetchMyPerspective failed: \(error.localizedDescription)") + PickeLogger.error("[PreVoteFeature] fetchMyPerspective failed: \(error.localizedDescription)", category: .ui) } return .none @@ -386,7 +380,7 @@ extension PreVoteFeature { case .success: state.myPerspective = nil case let .failure(error): - Log.error("[PreVoteFeature] deleteMyPerspective failed: \(error.localizedDescription)") + PickeLogger.error("[PreVoteFeature] deleteMyPerspective failed: \(error.localizedDescription)", category: .ui) } return .none @@ -404,7 +398,7 @@ extension PreVoteFeature { isMindChanged: false ))) case let .failure(error): - Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") + PickeLogger.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)", category: .ui) return .none } @@ -433,7 +427,7 @@ extension PreVoteFeature { case let .failure(error): // 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500. // 재투표가 불가하므로 결과(댓글) 화면으로 이동한다. - Log.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)") + PickeLogger.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)", category: .ui) return .send(.delegate(.alreadyFinalVoted(battleId: state.battleId))) } } @@ -472,7 +466,7 @@ extension PreVoteFeature { guard let leftOption = mapped[safe: 0], let rightOption = mapped[safe: 1] else { - Log.error("[PreVoteFeature] 서버 option 데이터 부족 count=\(mapped.count)") + PickeLogger.error("[PreVoteFeature] 서버 option 데이터 부족 count=\(mapped.count)", category: .ui) return nil } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Feature/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift similarity index 93% rename from Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift rename to Projects/Feature/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift index 419ab168..7dc35cc1 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ b/Projects/Feature/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -14,8 +14,8 @@ struct PreVoteSkeletonView: View { /// 다크 배경에선 shimmer 도 어두운 톤으로 (안드로이드 VoteScreen: base neutral600 / highlight neutral400). private func bar(cornerRadius: CGFloat) -> SkeletonView { isDark - ? SkeletonView(cornerRadius: cornerRadius, baseColor: .neutral600, shimmerColor: .neutral400) - : SkeletonView(cornerRadius: cornerRadius) + ? SkeletonView(.round(cornerRadius: cornerRadius), base: .neutral600, highlight: .neutral400) + : SkeletonView(.round(cornerRadius: cornerRadius)) } var body: some View { diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift b/Projects/Feature/Chat/Sources/Vote/View/PreVoteLayout.swift similarity index 100% rename from Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift rename to Projects/Feature/Chat/Sources/Vote/View/PreVoteLayout.swift diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Feature/Chat/Sources/Vote/View/PreVoteView.swift similarity index 93% rename from Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift rename to Projects/Feature/Chat/Sources/Vote/View/PreVoteView.swift index 791e8801..e72faf5c 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Feature/Chat/Sources/Vote/View/PreVoteView.swift @@ -6,12 +6,12 @@ // import SwiftUI +import UIKit import BattleDomainInterface import ComposableArchitecture -import Entity -import Kingfisher import PickeDesignKit +import PickeSharedUI @ViewAction(for: PreVoteFeature.self) public struct PreVoteView: View { @@ -69,7 +69,7 @@ public struct PreVoteView: View { } private var shouldShowSkeleton: Bool { - (store.isLoading || store.battle == nil || store.isCheckingParticipation) && !shouldShowLoadError + (store.viewState == .loading || store.battle == nil || store.isCheckingParticipation) && !shouldShowLoadError } @ViewBuilder @@ -97,14 +97,18 @@ public struct PreVoteView: View { backgroundImage(battle) .frame(width: proxy.size.width) - // 본문은 스크롤하지 않는다 — 옵션 카드는 항상 CTA 바로 위에 고정. + // 큰 화면에서는 옵션 카드를 CTA 바로 위에 유지하고, 긴 제목/작은 화면에서는 + // 콘텐츠 영역만 스크롤해 제목 전체를 읽을 수 있게 한다. let contentHeight = max(0, proxy.size.height - topInset - PreVoteLayout.ctaReservedHeight) VStack(spacing: 0) { Color.clear .frame(height: topInset) - contentArea(battle, minHeight: contentHeight) - .frame(height: contentHeight, alignment: .top) + ScrollView { + contentArea(battle, minHeight: contentHeight) + } + .scrollIndicators(.hidden) + .frame(height: contentHeight, alignment: .top) } .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) .safeAreaInset(edge: .bottom, spacing: 0) { @@ -130,10 +134,7 @@ extension PreVoteView { if let urlString = battle.backgroundImageURL, let url = URL(string: urlString) { - KFImage(url) - .placeholder { SkeletonView() } - .resizable() - .scaledToFill() + PickeRemoteImage(url: url) } else { Color.neutral200 } @@ -265,6 +266,7 @@ extension PreVoteView { .lineSpacing(24 * 0.4) .multilineTextAlignment(.leading) .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } @@ -341,18 +343,15 @@ extension PreVoteView { private func avatarView(imageURL: String, override: UIImage? = nil) -> some View { Group { if let override { - // 공유 스냅샷: 사전 로드된 이미지를 동기 렌더. Image(uiImage: override) .resizable() .scaledToFit() } else { - KFImage(URL(string: imageURL)) - .placeholder { - SkeletonView() - .frame(width: 28, height: 20) - } - .resizable() - .scaledToFit() + PickeRemoteImage(url: imageURL) { + SkeletonView(.round()) + .frame(width: 28, height: 20) + } + .content(.fit) } } .frame(width: 28, height: 20) @@ -386,7 +385,7 @@ extension PreVoteView { extension PreVoteView { @MainActor - private func captureCardSnapshot(_ request: PreVoteFeature.ShareSnapshotRequest) -> Data? { + private func captureCardSnapshot(_ request: ShareSnapshotRequest) -> Data? { guard let battle = store.battle else { return nil } let avatars = request.avatarImageData.compactMapValues(UIImage.init(data:)) let renderer = ImageRenderer(content: shareSnapshotCard(battle, avatarOverrides: avatars)) diff --git a/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift b/Projects/Feature/Chat/Tests/Sources/ChatTests.swift similarity index 83% rename from Projects/Presentation/Chat/Tests/Sources/ChatTests.swift rename to Projects/Feature/Chat/Tests/Sources/ChatTests.swift index 206e298e..a505aae6 100644 --- a/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift +++ b/Projects/Feature/Chat/Tests/Sources/ChatTests.swift @@ -1,6 +1,6 @@ // // ChatTests.swift -// Presentation.ChatTests +// Feature.ChatTests // // Created by Roy on 2026-05-21. // @@ -17,7 +17,6 @@ struct ChatTests { @Test func chatLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Feature/FeatureAssembly/Project.swift b/Projects/Feature/FeatureAssembly/Project.swift new file mode 100644 index 00000000..c143a097 --- /dev/null +++ b/Projects/Feature/FeatureAssembly/Project.swift @@ -0,0 +1,26 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "FeatureAssembly", + bundleId: .appBundleID(name: ".FeatureAssembly"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .feature(.auth, .implementation), + .feature(.web, .implementation), + .feature(.home, .implementation), + .feature(.chat, .implementation), + .feature(.hifi, .implementation), + .feature(.battle, .implementation), + .feature(.profile, .implementation), + .feature(.notification, .implementation), + .feature(.ad, .implementation), + .feature(.featureSharedUI, .implementation), + ] +) \ No newline at end of file diff --git a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift b/Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift similarity index 73% rename from Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift rename to Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift index 1eac8a02..31e1d7d3 100644 --- a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift +++ b/Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift @@ -1,12 +1,14 @@ // -// PresentationExported.swift -// Presentation +// FeatureAssemblyExported.swift +// FeatureAssembly // // Created by Wonji Suh on 9/5/25. // // MARK: - 여기에 한번에 호출 할꺼 추가 +@_exported import Ad +@_exported import FeatureSharedUI @_exported import Auth @_exported import Battle @_exported import Chat @@ -14,5 +16,4 @@ @_exported import Home @_exported import Notification @_exported import Profile -@_exported import Splash @_exported import Web diff --git a/Projects/Data/Model/ModelTests/Sources/Test.swift b/Projects/Feature/FeatureAssembly/Tests/Sources/Test.swift similarity index 100% rename from Projects/Data/Model/ModelTests/Sources/Test.swift rename to Projects/Feature/FeatureAssembly/Tests/Sources/Test.swift diff --git a/Projects/Feature/FeatureSharedUI/Project.swift b/Projects/Feature/FeatureSharedUI/Project.swift new file mode 100644 index 00000000..5288379f --- /dev/null +++ b/Projects/Feature/FeatureSharedUI/Project.swift @@ -0,0 +1,19 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "FeatureSharedUI", + bundleId: .appBundleID(name: ".FeatureSharedUI"), + product: .staticFramework, + settings: .settings(), + // 피처 여러 곳이 함께 쓰는 화면 조각. 피처끼리 서로의 구현을 직접 물지 않게 여기로 모은다. + dependencies: [ + .core(.logger), + .SPM.adFit, + ] +) diff --git a/Projects/Shared/AdKit/Sources/AdBannerSkeletonView.swift b/Projects/Feature/FeatureSharedUI/Sources/AdBannerSkeletonView.swift similarity index 99% rename from Projects/Shared/AdKit/Sources/AdBannerSkeletonView.swift rename to Projects/Feature/FeatureSharedUI/Sources/AdBannerSkeletonView.swift index 081cda2c..ae1e11b6 100644 --- a/Projects/Shared/AdKit/Sources/AdBannerSkeletonView.swift +++ b/Projects/Feature/FeatureSharedUI/Sources/AdBannerSkeletonView.swift @@ -1,6 +1,6 @@ // // AdBannerSkeletonView.swift -// AdKit +// Ad // import SwiftUI diff --git a/Projects/Shared/AdKit/Sources/AdFitBannerView.swift b/Projects/Feature/FeatureSharedUI/Sources/AdFitBannerView.swift similarity index 93% rename from Projects/Shared/AdKit/Sources/AdFitBannerView.swift rename to Projects/Feature/FeatureSharedUI/Sources/AdFitBannerView.swift index 4d9a5837..ac8fcedb 100644 --- a/Projects/Shared/AdKit/Sources/AdFitBannerView.swift +++ b/Projects/Feature/FeatureSharedUI/Sources/AdFitBannerView.swift @@ -59,6 +59,7 @@ public struct AdFitBannerView: View { private let unit: AdFitBannerUnit private let insets: EdgeInsets private let alignment: Alignment + private let onAdClick: () -> Void /// 배너 로드 진행 상태. 로딩 동안은 스켈레톤, 성공하면 광고, 실패하면 자리를 접는다. private enum LoadState { @@ -76,14 +77,18 @@ public struct AdFitBannerView: View { /// 주변 콘텐츠와 좌측 라인을 맞추려면 호출부가 콘텐츠와 동일한 여백을 넘겨준다. /// 광고가 없으면 뷰가 통째로 접혀 이 여백도 남지 않는다. /// - alignment: 고정폭 배너의 정렬. 콘텐츠 좌측에 맞추려면 `.leading`. + /// - onAdClick: 배너를 눌렀을 때. AdKit 은 분석 모듈을 의존하지 않으므로 + /// 트래킹은 호출한 화면이 담당한다. public init( unit: AdFitBannerUnit = .size320x50, insets: EdgeInsets = EdgeInsets(), - alignment: Alignment = .center + alignment: Alignment = .center, + onAdClick: @escaping () -> Void = {} ) { self.unit = unit self.insets = insets self.alignment = alignment + self.onAdClick = onAdClick } public var body: some View { @@ -99,6 +104,7 @@ public struct AdFitBannerView: View { // 노출할 광고가 없는 경우(에러 코드 2)도 포함해 실패하면 자리를 비운다. loadState = (error == nil) ? .loaded : .failed } + .onDidClickAd { _ in onAdClick() } .onSizeThatFits(orientation: $orientation, width: contentWidth) .frame( width: max(contentWidth, 1), diff --git a/Projects/Shared/AdKit/Sources/AdFitNativeAdView.swift b/Projects/Feature/FeatureSharedUI/Sources/AdFitNativeAdView.swift similarity index 86% rename from Projects/Shared/AdKit/Sources/AdFitNativeAdView.swift rename to Projects/Feature/FeatureSharedUI/Sources/AdFitNativeAdView.swift index 323e91d2..41fd1230 100644 --- a/Projects/Shared/AdKit/Sources/AdFitNativeAdView.swift +++ b/Projects/Feature/FeatureSharedUI/Sources/AdFitNativeAdView.swift @@ -1,9 +1,9 @@ // // AdFitNativeAdView.swift -// AdKit +// Ad // -import OSLog +import PickeCoreLogger import SwiftUI import UIKit @@ -42,13 +42,9 @@ public enum AdFitNativeAdUnit: Sendable { /// SDK 가 계산해 준 높이를 그대로 프레임에 반영하고, 광고가 없으면 SDK 가 높이 0 으로 /// 접어 주므로 실패 자리도 자연히 사라진다. public struct AdFitNativeAdView: View { - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Picke", - category: "AdFit.Native" - ) - private let unit: AdFitNativeAdUnit private let insets: EdgeInsets + private let onAdClick: () -> Void /// 광고 수신 완료 여부. 완료 전까지는 스켈레톤으로 자리를 잡는다. @State private var loaded = false @@ -58,12 +54,16 @@ public struct AdFitNativeAdView: View { /// - unit: 노출할 네이티브 광고 비율. /// - insets: 광고가 **실제로 노출될 때만** 적용되는 여백. 광고 단위가 없으면 /// 뷰가 통째로 접혀 이 여백도 남지 않는다. + /// - onAdClick: 광고를 눌렀을 때. AdKit 은 분석 모듈을 의존하지 않으므로 + /// 트래킹은 호출한 화면이 담당한다. public init( unit: AdFitNativeAdUnit, - insets: EdgeInsets = EdgeInsets() + insets: EdgeInsets = EdgeInsets(), + onAdClick: @escaping () -> Void = {} ) { self.unit = unit self.insets = insets + self.onAdClick = onAdClick } public var body: some View { @@ -81,16 +81,13 @@ public struct AdFitNativeAdView: View { loaded: $loaded, failed: $failed, onReceive: { - Self.logger.info( - "AdFit 네이티브 광고 수신 성공: \(unit.infoPlistKey, privacy: .public)" - ) + PickeLogger.info("AdFit 네이티브 광고 수신 성공: \(unit.infoPlistKey)", category: .ui) }, onFailure: { error in let nsError = error as NSError - Self.logger.error( - "AdFit 네이티브 광고 수신 실패: \(unit.infoPlistKey, privacy: .public), domain=\(nsError.domain, privacy: .public), code=\(nsError.code), \(error.localizedDescription, privacy: .public)" - ) - } + PickeLogger.error("AdFit 네이티브 광고 수신 실패: \(unit.infoPlistKey), domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)", category: .ui) + }, + onClick: onAdClick ) .aspectRatio(unit.aspectRatio, contentMode: .fit) .opacity(loaded ? 1 : 0) @@ -118,6 +115,7 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { @Binding var failed: Bool let onReceive: () -> Void let onFailure: (Error) -> Void + let onClick: () -> Void func makeCoordinator() -> Coordinator { Coordinator( @@ -125,7 +123,8 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { loaded: $loaded, failed: $failed, onReceive: onReceive, - onFailure: onFailure + onFailure: onFailure, + onClick: onClick ) } @@ -146,12 +145,13 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { } @MainActor - final class Coordinator: NSObject, AdFitNativeAdLoaderDelegate { + final class Coordinator: NSObject, AdFitNativeAdLoaderDelegate, AdFitNativeAdDelegate { private let loader: AdFitNativeAdLoader private let loaded: Binding private let failed: Binding private let onReceive: () -> Void private let onFailure: (Error) -> Void + private let onClick: () -> Void private weak var nativeAdView: PlainNativeAdView? private var nativeAd: AdFitNativeAd? private var retryTask: Task? @@ -162,7 +162,7 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { .seconds(15), .seconds(30), .seconds(60), - .seconds(120) + .seconds(120), ] init( @@ -170,13 +170,15 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { loaded: Binding, failed: Binding, onReceive: @escaping () -> Void, - onFailure: @escaping (Error) -> Void + onFailure: @escaping (Error) -> Void, + onClick: @escaping () -> Void ) { loader = AdFitNativeAdLoader(clientId: clientId) self.loaded = loaded self.failed = failed self.onReceive = onReceive self.onFailure = onFailure + self.onClick = onClick super.init() loader.delegate = self } @@ -217,6 +219,7 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { retryTask = nil isRequesting = false self.nativeAd = nativeAd + nativeAd.delegate = self nativeAd.rootViewController = nativeAdView.window?.rootViewController nativeAd.bind(nativeAdView) failed.wrappedValue = false @@ -224,8 +227,12 @@ private struct AdFitNativeAdRepresentable: UIViewRepresentable { onReceive() } + func nativeAdDidClickAd(_: AdFitNativeAd) { + onClick() + } + func nativeAdLoaderDidFailToReceiveAd( - _ nativeAdLoader: AdFitNativeAdLoader, + _: AdFitNativeAdLoader, error: Error ) { isRequesting = false diff --git a/Projects/Presentation/Hifi/Interface/Sources/HifiInterface.swift b/Projects/Feature/Hifi/Interface/Sources/HifiInterface.swift similarity index 100% rename from Projects/Presentation/Hifi/Interface/Sources/HifiInterface.swift rename to Projects/Feature/Hifi/Interface/Sources/HifiInterface.swift diff --git a/Projects/Feature/Hifi/Project.swift b/Projects/Feature/Hifi/Project.swift new file mode 100644 index 00000000..1abe1212 --- /dev/null +++ b/Projects/Feature/Hifi/Project.swift @@ -0,0 +1,32 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Hifi", + bundleId: .appBundleID(name: ".Hifi"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .domain(.battle, .interface), + .ui(.designKit), + .ui(.sharedUI), + .core(.coreUtility), + .service(.analytics, .interface), + // 탐색 리스트 인라인 배너 광고 — 광고를 노출하는 화면만 명시적으로 의존한다. + .feature(.featureSharedUI, .implementation), + .domain(.home, .interface), + .domain(.search, .interface), + .domain(.notification, .interface), + .SPM.composableArchitecture, + .SPM.kingfisher, + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift b/Projects/Feature/Hifi/Sources/Reducer/HifiFeature.swift similarity index 88% rename from Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift rename to Projects/Feature/Hifi/Sources/Reducer/HifiFeature.swift index 68740304..0dff23e8 100644 --- a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift +++ b/Projects/Feature/Hifi/Sources/Reducer/HifiFeature.swift @@ -4,15 +4,15 @@ // import Foundation +import PickeCoreLogger import SearchDomainInterface import ComposableArchitecture -import Entity import HifiInterface import HomeDomainInterface -import LogMacro import NotificationDomainInterface -import UseCase +import PickeAnalyticsInterface +import BattleDomainInterface @Reducer public struct HifiFeature { @@ -24,7 +24,13 @@ public struct HifiFeature { public var selectedCategory: ExploreCategory = .all public var selectedSort: ExploreSort = .popular public var items: [ExploreItem] = [] - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var nextOffset: Int? public var hasNext: Bool = false @@ -50,6 +56,8 @@ public struct HifiFeature { case itemTapped(id: Int) case reachedBottom case notificationTapped + /// 탐색 화면 배너 광고 클릭 + case adBannerClicked } public enum AsyncAction: Equatable { @@ -127,8 +135,12 @@ extension HifiFeature { case .reachedBottom: // 무한 스크롤: 다음 페이지가 있고 로딩 중이 아니면 추가 로드. - guard state.hasNext, !state.isLoading else { return .none } + guard state.hasNext, state.viewState != .loading else { return .none } return .send(.async(.searchRequested(reset: false))) + + case .adBannerClicked: + analyticsUseCase.track(.adClick(AdClickData(placement: .explore, format: .banner, unit: "ADFIT_BANNER_320X100"))) + return .none } } @@ -147,7 +159,7 @@ extension HifiFeature { ) -> Effect { switch action { case let .searchRequested(reset): - state.isLoading = true + state.viewState = .loading if reset { state.items = [] } let category = state.selectedCategory.queryValue let sort = state.selectedSort.queryValue @@ -176,14 +188,14 @@ extension HifiFeature { ) -> Effect { switch action { case let .searchResponse(result, reset): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(page): state.items = reset ? page.items : state.items + page.items state.nextOffset = page.nextOffset state.hasNext = page.hasNext case let .failure(error): - Log.error("[HifiFeature] searchBattles failed: \(error.localizedDescription)") + PickeLogger.error("[HifiFeature] searchBattles failed: \(error.localizedDescription)", category: .ui) if reset { state.items = [] } } return .none diff --git a/Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift b/Projects/Feature/Hifi/Sources/View/Components/ExploreSkeletonView.swift similarity index 56% rename from Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift rename to Projects/Feature/Hifi/Sources/View/Components/ExploreSkeletonView.swift index 08117bab..797adaaf 100644 --- a/Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift +++ b/Projects/Feature/Hifi/Sources/View/Components/ExploreSkeletonView.swift @@ -21,23 +21,23 @@ struct ExploreSkeletonView: View { @ViewBuilder private func row() -> some View { HStack(alignment: .center, spacing: 8) { - SkeletonView(cornerRadius: .radiusDefault) + SkeletonView(.round(cornerRadius: .radiusDefault)) .frame(width: 76, height: 76) VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { - SkeletonView(cornerRadius: .radiusDefault).frame(width: 44, height: 18) - SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 14) + SkeletonView(.round(cornerRadius: .radiusDefault)).frame(width: 44, height: 18) + SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 14) } - SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) - SkeletonView(cornerRadius: 4).frame(width: 180, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 180, height: 12) } HStack(spacing: 6) { Spacer() - SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) - SkeletonView(cornerRadius: 4).frame(width: 44, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 36, height: 12) + SkeletonView(.round(cornerRadius: 4)).frame(width: 44, height: 12) } } } diff --git a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift b/Projects/Feature/Hifi/Sources/View/Components/HifiHeaderView.swift similarity index 100% rename from Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift rename to Projects/Feature/Hifi/Sources/View/Components/HifiHeaderView.swift diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Feature/Hifi/Sources/View/HifiView.swift similarity index 97% rename from Projects/Presentation/Hifi/Sources/View/HifiView.swift rename to Projects/Feature/Hifi/Sources/View/HifiView.swift index f973f4ec..90166406 100644 --- a/Projects/Presentation/Hifi/Sources/View/HifiView.swift +++ b/Projects/Feature/Hifi/Sources/View/HifiView.swift @@ -5,13 +5,12 @@ import SwiftUI -import AdKit +import FeatureSharedUI import ComposableArchitecture -import Entity import HomeDomainInterface -import Kingfisher +import PickeSharedUI +import PickeCoreUtility import PickeDesignKit -import Utill @ViewAction(for: HifiFeature.self) public struct HifiView: View { @@ -59,7 +58,7 @@ private extension HifiView { @ViewBuilder func contentArea() -> some View { Group { - if store.isLoading, store.items.isEmpty { + if store.viewState == .loading, store.items.isEmpty { skeletonList() } else if store.items.isEmpty { emptyState() @@ -142,7 +141,8 @@ private extension HifiView { func adBannerRow() -> some View { AdFitBannerView( unit: .size320x100, - insets: EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) + insets: EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16), + onAdClick: { send(.adBannerClicked) } ) } @@ -269,10 +269,7 @@ private extension HifiView { func thumbnail(_ url: String?) -> some View { Group { if let url, let imageURL = URL(string: url) { - KFImage(imageURL) - .placeholder { Color.beige600 } - .resizable() - .scaledToFill() + PickeRemoteImage(url: imageURL) { Color.beige600 } } else { Color.beige600 } diff --git a/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift b/Projects/Feature/Hifi/Tests/Sources/HifiTests.swift similarity index 84% rename from Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift rename to Projects/Feature/Hifi/Tests/Sources/HifiTests.swift index 21c54d00..fb23bdb8 100644 --- a/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift +++ b/Projects/Feature/Hifi/Tests/Sources/HifiTests.swift @@ -1,6 +1,6 @@ // // HifiTests.swift -// Presentation.HifiTests +// Feature.HifiTests // // Created by Roy on 2026-06-04. // @@ -18,7 +18,6 @@ struct HifiTests { @Test func hifiLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Presentation/Home/Interface/Sources/HomeInterface.swift b/Projects/Feature/Home/Interface/Sources/HomeInterface.swift similarity index 100% rename from Projects/Presentation/Home/Interface/Sources/HomeInterface.swift rename to Projects/Feature/Home/Interface/Sources/HomeInterface.swift diff --git a/Projects/Feature/Home/Project.swift b/Projects/Feature/Home/Project.swift new file mode 100644 index 00000000..dc9cfaba --- /dev/null +++ b/Projects/Feature/Home/Project.swift @@ -0,0 +1,33 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Home", + bundleId: .appBundleID(name: ".Home"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .SPM.composableArchitecture, + .domain(.auth, .interface), + .ui(.designKit), + .ui(.sharedUI), + .service(.analytics, .interface), + .SPM.tcaFlow, + .SPM.kingfisher, + .domain(.attendance, .interface), + .domain(.battle, .interface), + .domain(.home, .interface), + .domain(.notification, .interface), + // 홈 피드 중간 배너 광고 — 광고를 노출하는 화면만 명시적으로 의존한다. + .feature(.featureSharedUI, .implementation), + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Presentation/Home/Sources/Attendance/Reducer/AttendanceModalFeature.swift b/Projects/Feature/Home/Sources/Attendance/Reducer/AttendanceModalFeature.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Attendance/Reducer/AttendanceModalFeature.swift rename to Projects/Feature/Home/Sources/Attendance/Reducer/AttendanceModalFeature.swift diff --git a/Projects/Presentation/Home/Sources/Attendance/View/AttendanceDayCell.swift b/Projects/Feature/Home/Sources/Attendance/View/AttendanceDayCell.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Attendance/View/AttendanceDayCell.swift rename to Projects/Feature/Home/Sources/Attendance/View/AttendanceDayCell.swift diff --git a/Projects/Presentation/Home/Sources/Attendance/View/AttendanceModalView.swift b/Projects/Feature/Home/Sources/Attendance/View/AttendanceModalView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Attendance/View/AttendanceModalView.swift rename to Projects/Feature/Home/Sources/Attendance/View/AttendanceModalView.swift diff --git a/Projects/Presentation/Home/Sources/Attendance/View/AttendanceSheetView.swift b/Projects/Feature/Home/Sources/Attendance/View/AttendanceSheetView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Attendance/View/AttendanceSheetView.swift rename to Projects/Feature/Home/Sources/Attendance/View/AttendanceSheetView.swift diff --git a/Projects/Presentation/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift b/Projects/Feature/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift similarity index 95% rename from Projects/Presentation/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift rename to Projects/Feature/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift index c1162451..749f8376 100644 --- a/Projects/Presentation/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift +++ b/Projects/Feature/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift @@ -4,7 +4,6 @@ // import PickeDesignKit -import Entity import BattleDomainInterface extension PhilosopherAvatar { diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Feature/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift rename to Projects/Feature/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Feature/Home/Sources/Coordinator/View/HomeCoordinatorView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift rename to Projects/Feature/Home/Sources/Coordinator/View/HomeCoordinatorView.swift diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift similarity index 90% rename from Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift rename to Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift index 4ea8cb53..d288b483 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift @@ -6,16 +6,14 @@ // import AttendanceDomainInterface +import AuthDomainInterface import ComposableArchitecture -import DomainInterface -import Entity import Foundation import HomeDomainInterface import HomeInterface -import LogMacro import NotificationDomainInterface -import Shared -import UseCase +import PickeAnalyticsInterface +import PickeCoreLogger @Reducer public struct HomeFeature { @@ -23,7 +21,13 @@ public struct HomeFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var hasLoadedHome: Bool = false public var newNotice: Bool = false public var heroes: [HeroBattle] = [] @@ -45,6 +49,16 @@ public struct HomeFeature { public var currentQuiz: QuizQuestion? { quizzes.first } public var currentVote: VoteQuestion? { votes.first } + public var shouldShowSkeleton: Bool { + (viewState == .loading || !hasLoadedHome) && + heroes.isEmpty && + hotBattles.isEmpty && + bestBattles.isEmpty && + quizzes.isEmpty && + votes.isEmpty && + newBattles.isEmpty + } + public init() {} } @@ -68,6 +82,8 @@ public struct HomeFeature { case bestBattleTapped(BestBattle) case newBattleTapped(NewBattle) case notificationTapped + /// 홈 피드 네이티브 광고 클릭 + case adNativeClicked } public enum Section: Equatable { @@ -148,13 +164,13 @@ extension HomeFeature { ? .none : .send(.async(.checkAttendance)) state.hasTriedAttendance = true - guard !state.hasLoadedHome, !state.isLoading else { + guard !state.hasLoadedHome, state.viewState != .loading else { return .merge(syncBadge, attendance) } return .merge(syncBadge, attendance, .send(.async(.fetchHome))) case .pullToRefresh: - guard !state.isLoading else { return .none } + guard state.viewState != .loading else { return .none } return .send(.async(.fetchHome)) case .seeMoreTapped: @@ -200,6 +216,10 @@ extension HomeFeature { case .notificationTapped: analyticsUseCase.track(.uiAction(action: .homeNotification, screen: .home)) return .send(.delegate(.openNotification)) + + case .adNativeClicked: + analyticsUseCase.track(.adClick(AdClickData(placement: .home, format: .native, unit: "ADFIT_NATIVE_2_1"))) + return .none } } @@ -209,7 +229,7 @@ extension HomeFeature { ) -> Effect { switch action { case .fetchHome: - state.isLoading = true + state.viewState = .loading return .run { [repository = homeUseCase] send in let result = await Result { try await repository.fetchHome() @@ -248,7 +268,7 @@ extension HomeFeature { ) -> Effect { switch action { case let .homeResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(bundle): state.hasLoadedHome = true @@ -262,7 +282,7 @@ extension HomeFeature { state.votes = home.votes state.newBattles = home.newBattles case let .failure(error): - Log.error("[HomeFeature] fetchHome failed: \(error.localizedDescription)") + PickeLogger.error("[HomeFeature] fetchHome failed: \(error.localizedDescription)", category: .ui) } return .none diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift similarity index 99% rename from Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift index d05992d5..a4fc07ec 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -7,7 +7,6 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift similarity index 96% rename from Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift index eca3d08f..8aeb3dd4 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -7,11 +7,10 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit -import Kingfisher +import PickeSharedUI /// 최상단 Editor Pick 캐러셀. 좌우 스와이프 + 3초마다 자동 스크롤, 마지막 뒤엔 처음으로 wrap. struct HeroCarouselView: View { @@ -107,10 +106,7 @@ struct HeroCardView: View { .fill(.neutral500.opacity(0.4)) if let url = hero.thumbnailURL { - KFImage(url) - .placeholder { SkeletonView() } - .resizable() - .aspectRatio(contentMode: .fill) + PickeRemoteImage(url: url) .frame( width: proxy.size.width, height: proxy.size.height, diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeHeaderView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/HomeHeaderView.swift diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift rename to Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift similarity index 84% rename from Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift index e44d0b40..4b2e865b 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift @@ -263,62 +263,8 @@ private struct SkeletonBlock: View { } var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(color) + SkeletonView(.round(cornerRadius: cornerRadius), base: color) .frame(width: width, height: height) .frame(maxWidth: width == nil ? .infinity : nil) - .skeletonShimmer(cornerRadius: cornerRadius) - } -} - -private struct SkeletonShimmerModifier: ViewModifier { - @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion - @State private var isShimmering = false - - let cornerRadius: CGFloat - - func body(content: Content) -> some View { - content - .overlay { - if !accessibilityReduceMotion { - shimmer - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - } - } - .onAppear { - guard !accessibilityReduceMotion else { return } - isShimmering = true - } - } - - @ViewBuilder - private var shimmer: some View { - GeometryReader { proxy in - LinearGradient( - colors: [ - .clear, - .white.opacity(0.32), - .clear - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: proxy.size.width * 0.55, height: proxy.size.height) - .offset(x: isShimmering ? proxy.size.width : -proxy.size.width) - .blendMode(.screen) - .allowsHitTesting(false) - .animation( - .linear(duration: 1.6) - .delay(0.15) - .repeatForever(autoreverses: false), - value: isShimmering - ) - } - } -} - -private extension View { - func skeletonShimmer(cornerRadius: CGFloat) -> some View { - modifier(SkeletonShimmerModifier(cornerRadius: cornerRadius)) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift similarity index 91% rename from Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift index 08edb507..8413c978 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift @@ -7,11 +7,10 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit -import Kingfisher +import PickeSharedUI /// "지금 뜨는 배틀" 가로 스크롤 카드 (220 wide). struct HotBattleCardView: View { @@ -48,10 +47,7 @@ struct HotBattleCardView: View { // QA-43: Figma node 3888-3736 기준 — height 140, border 4pt(.borderBeigeSelected), radius 2. Group { if let url = battle.thumbnailURL { - KFImage(url) - .placeholder { SkeletonView() } - .resizable() - .scaledToFill() + PickeRemoteImage(url: url) } else { Rectangle() .fill(.beige500) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/MetaLabelView.swift b/Projects/Feature/Home/Sources/Main/View/Components/MetaLabelView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Main/View/Components/MetaLabelView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/MetaLabelView.swift diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift similarity index 95% rename from Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift index 6f17da8f..a33222dc 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -7,11 +7,10 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit -import Kingfisher +import PickeSharedUI /// "새로운 배틀" 리스트 카드 (.pen `Card/BattleListCard` 의 thumbnail 제외 구성). struct NewBattleCardView: View { @@ -132,10 +131,8 @@ extension NewBattleCardView { .fill(.beige600) .frame(width: 40, height: 40) if let imageURL { - KFImage(imageURL) - .placeholder { SkeletonView(cornerRadius: 20) } - .resizable() - .scaledToFit() + PickeRemoteImage(url: imageURL, shape: .round(cornerRadius: 20)) + .content(.fit) .frame(width: 20, height: 38) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/QuizCardView.swift similarity index 99% rename from Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/QuizCardView.swift index c674fecb..59dad27e 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/QuizCardView.swift @@ -7,7 +7,6 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/VoteCardView.swift similarity index 99% rename from Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift rename to Projects/Feature/Home/Sources/Main/View/Components/VoteCardView.swift index cac38cdb..2bc7879f 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/VoteCardView.swift @@ -7,7 +7,6 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Feature/Home/Sources/Main/View/HomeView.swift similarity index 88% rename from Projects/Presentation/Home/Sources/Main/View/HomeView.swift rename to Projects/Feature/Home/Sources/Main/View/HomeView.swift index e3a63b7e..dcf8583a 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Feature/Home/Sources/Main/View/HomeView.swift @@ -7,12 +7,12 @@ import SwiftUI -import Entity import HomeDomainInterface import PickeDesignKit +import PickeSharedUI -import AdKit import ComposableArchitecture +import FeatureSharedUI @ViewAction(for: HomeFeature.self) public struct HomeView: View { @@ -30,7 +30,7 @@ public struct HomeView: View { ) // sticky — 스크롤 영향 없음 ScrollView(showsIndicators: false) { - if shouldShowSkeleton { + if store.shouldShowSkeleton { HomeSkeletonView() } else { VStack(spacing: 32) { @@ -83,23 +83,14 @@ public struct HomeView: View { // MARK: - Sections extension HomeView { - private var shouldShowSkeleton: Bool { - // hasLoadedHome 이 아직 false 인 첫 프레임(onAppear 도착 전)도 스켈레톤으로 - // 덮는다 — isLoading 만 보면 빈 콘텐츠 위 광고 자리만 먼저 번쩍인다. - (store.isLoading || !store.hasLoadedHome) && - store.heroes.isEmpty && - store.hotBattles.isEmpty && - store.bestBattles.isEmpty && - store.quizzes.isEmpty && - store.votes.isEmpty && - store.newBattles.isEmpty - } - /// 배틀 섹션 아래 네이티브 광고 — AdFit 은 한 화면에 같은 단위 1개만 허용해 호출부에서 하나만 그린다. @ViewBuilder private func adSection() -> some View { - AdFitNativeAdView(unit: .wide) - .frame(maxWidth: .infinity) + AdFitNativeAdView( + unit: .wide, + onAdClick: { send(.adNativeClicked) } + ) + .frame(maxWidth: .infinity) } @ViewBuilder diff --git a/Projects/Presentation/Home/Tests/Sources/HomeTests.swift b/Projects/Feature/Home/Tests/Sources/HomeTests.swift similarity index 56% rename from Projects/Presentation/Home/Tests/Sources/HomeTests.swift rename to Projects/Feature/Home/Tests/Sources/HomeTests.swift index b3c4ec69..35b5386b 100644 --- a/Projects/Presentation/Home/Tests/Sources/HomeTests.swift +++ b/Projects/Feature/Home/Tests/Sources/HomeTests.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Entity import Foundation import HomeDomainInterface import Testing @@ -7,6 +6,30 @@ import Testing @testable import Home struct HomeTests { + @Test + func initialEmptyHomeShowsSkeletonBeforeOnAppear() { + let state = HomeFeature.State() + + #expect(state.shouldShowSkeleton) + } + + @Test + @MainActor + func failedHomeResponseAllowsRetryOnReentry() async { + var state = HomeFeature.State() + state.viewState = .loading + let store = TestStore(initialState: state) { + HomeFeature() + } + + await store.send(.inner(.homeResponse(.failure(.networkError("offline"))))) { + $0.viewState = .loaded + } + + #expect(store.state.hasLoadedHome == false) + #expect(store.state.shouldShowSkeleton) + } + @Test @MainActor func homeResponseDoesNotTouchNotificationBadge() async { @@ -21,6 +44,7 @@ struct HomeTests { } #expect(store.state.hasUnreadNotification == false) + #expect(store.state.shouldShowSkeleton == false) } } diff --git a/Projects/Presentation/Notification/Interface/Sources/NotificationInterface.swift b/Projects/Feature/Notification/Interface/Sources/NotificationInterface.swift similarity index 100% rename from Projects/Presentation/Notification/Interface/Sources/NotificationInterface.swift rename to Projects/Feature/Notification/Interface/Sources/NotificationInterface.swift diff --git a/Projects/Feature/Notification/Project.swift b/Projects/Feature/Notification/Project.swift new file mode 100644 index 00000000..f0d5727f --- /dev/null +++ b/Projects/Feature/Notification/Project.swift @@ -0,0 +1,26 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Notification", + bundleId: .appBundleID(name: ".Notification"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .ui(.designKit), + .ui(.sharedUI), + .core(.coreUtility), + .service(.analytics, .interface), + .domain(.notification, .interface), + .SPM.composableArchitecture, + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) diff --git a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift b/Projects/Feature/Notification/Sources/Main/Reducer/NotificationFeature.swift similarity index 91% rename from Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift rename to Projects/Feature/Notification/Sources/Main/Reducer/NotificationFeature.swift index d663a92e..d8997104 100644 --- a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift +++ b/Projects/Feature/Notification/Sources/Main/Reducer/NotificationFeature.swift @@ -4,13 +4,12 @@ // import Foundation +import PickeCoreLogger import ComposableArchitecture -import Entity -import LogMacro import NotificationDomainInterface -import Shared -import UseCase +import PickeAnalyticsInterface +import PickeCoreUtility @Reducer public struct NotificationFeature { @@ -21,7 +20,13 @@ public struct NotificationFeature { @ObservableState public struct State: Equatable { public var selectedTab: NotificationCategory = .all - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var isLoadingMore: Bool = false public var items: [NotificationItem] = [] public var page: Int = 0 @@ -120,7 +125,7 @@ extension NotificationFeature { return .send(.async(.fetch(reset: true))) case .reachedBottom: - guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + guard state.hasNext, !state.isLoadingMore, state.viewState != .loading else { return .none } return .send(.async(.fetch(reset: false))) case .readAllTapped: @@ -170,7 +175,7 @@ extension NotificationFeature { switch action { case let .fetch(reset): if reset { - state.isLoading = true + state.viewState = .loading } else { state.isLoadingMore = true } @@ -208,7 +213,7 @@ extension NotificationFeature { ) -> Effect { switch action { case let .notificationsResponse(result, reset): - state.isLoading = false + state.viewState = .loaded state.isLoadingMore = false switch result { case let .success(pageData): @@ -220,7 +225,7 @@ extension NotificationFeature { state.hasNext = pageData.hasNext if pageData.hasNext { state.page += 1 } case let .failure(error): - Log.error("[NotificationFeature] fetchNotifications failed: \(error.localizedDescription)") + PickeLogger.error("[NotificationFeature] fetchNotifications failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift b/Projects/Feature/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift similarity index 91% rename from Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift rename to Projects/Feature/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift index 1e25ecbd..f4a00b0e 100644 --- a/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift +++ b/Projects/Feature/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift @@ -13,7 +13,7 @@ struct NotificationSkeletonView: View { VStack(spacing: 8) { ForEach(0 ..< 7, id: \.self) { _ in HStack(alignment: .center, spacing: 16) { - SkeletonBlock(cornerRadius: 12, tone: .light) + SkeletonView(.round(cornerRadius: 12)) .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 6) { @@ -42,7 +42,7 @@ struct NotificationSkeletonView: View { maxWidth: Bool = false, height: CGFloat ) -> some View { - SkeletonBlock(cornerRadius: 4, tone: .light) + SkeletonView(.round(cornerRadius: 4)) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) diff --git a/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift b/Projects/Feature/Notification/Sources/Main/View/NotificationView.swift similarity index 98% rename from Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift rename to Projects/Feature/Notification/Sources/Main/View/NotificationView.swift index 93ea45e5..801b7608 100644 --- a/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift +++ b/Projects/Feature/Notification/Sources/Main/View/NotificationView.swift @@ -8,7 +8,8 @@ import SwiftUI import ComposableArchitecture import NotificationDomainInterface import PickeDesignKit -import Utill +import PickeSharedUI +import PickeCoreUtility @ViewAction(for: NotificationFeature.self) public struct NotificationView: View { @@ -37,7 +38,7 @@ public struct NotificationView: View { tabBar() Group { - if store.isLoading { + if store.viewState == .loading { NotificationSkeletonView() } else { content() diff --git a/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift b/Projects/Feature/Notification/Tests/Sources/NotificationTests.swift similarity index 83% rename from Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift rename to Projects/Feature/Notification/Tests/Sources/NotificationTests.swift index 7d4136bf..9645c70e 100644 --- a/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift +++ b/Projects/Feature/Notification/Tests/Sources/NotificationTests.swift @@ -1,6 +1,6 @@ // // NotificationTests.swift -// Presentation.NotificationTests +// Feature.NotificationTests // // Created by Roy on 2026-06-10. // @@ -17,7 +17,6 @@ struct NotificationTests { @Test func notificationLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Presentation/Profile/Interface/Sources/ProfileInterface.swift b/Projects/Feature/Profile/Interface/Sources/ProfileInterface.swift similarity index 50% rename from Projects/Presentation/Profile/Interface/Sources/ProfileInterface.swift rename to Projects/Feature/Profile/Interface/Sources/ProfileInterface.swift index 22e1ba88..054f13a6 100644 --- a/Projects/Presentation/Profile/Interface/Sources/ProfileInterface.swift +++ b/Projects/Feature/Profile/Interface/Sources/ProfileInterface.swift @@ -1 +1,3 @@ +import ProfileDomainInterface + public enum ProfileInterface {} diff --git a/Projects/Feature/Profile/Project.swift b/Projects/Feature/Profile/Project.swift new file mode 100644 index 00000000..6b181aca --- /dev/null +++ b/Projects/Feature/Profile/Project.swift @@ -0,0 +1,40 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Profile", + bundleId: .appBundleID(name: ".Profile"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.logger), + .core(.storage, .interface), + .ui(.designKit), + .ui(.sharedUI), + .core(.coreUtility), + .service(.auth, .interface), + .service(.device, .interface), + .service(.analytics, .interface), + .domain(.profile, .interface), + .domain(.auth, .interface), + .domain(.battle, .interface), + .domain(.notification, .interface), + .feature(.featureSharedUI, .implementation), // 마이페이지 하단 배너 광고 + // 리워드 광고 계약(RewardedAdClient)은 Ad Interface 에서 온다. + .feature(.ad), + .SPM.composableArchitecture, + .SPM.tcaFlow, + .SPM.kingfisher, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .domain(.profile, .interface), + ], + hasTesting: false +) diff --git a/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift b/Projects/Feature/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift similarity index 97% rename from Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift rename to Projects/Feature/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift index cf10e4e3..47a64ff8 100644 --- a/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift +++ b/Projects/Feature/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift @@ -4,13 +4,12 @@ // import Foundation +import PickeCoreLogger import ComposableArchitecture import PickeDesignKit -import Entity +import PickeSharedUI import BattleDomainInterface -import LogMacro -import UseCase @Reducer public struct BattleProposalFeature { @@ -175,7 +174,7 @@ extension BattleProposalFeature { ) return .none case let .failure(error): - Log.error("[BattleProposalFeature] proposeBattle failed: \(error.localizedDescription)") + PickeLogger.error("[BattleProposalFeature] proposeBattle failed: \(error.localizedDescription)", category: .ui) return .none } } diff --git a/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift b/Projects/Feature/Profile/Sources/BattleProposal/View/BattleProposalView.swift similarity index 99% rename from Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift rename to Projects/Feature/Profile/Sources/BattleProposal/View/BattleProposalView.swift index 12dd3722..86b93f2a 100644 --- a/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift +++ b/Projects/Feature/Profile/Sources/BattleProposal/View/BattleProposalView.swift @@ -7,7 +7,7 @@ import SwiftUI import ComposableArchitecture import PickeDesignKit -import Entity +import PickeSharedUI import BattleDomainInterface @ViewAction(for: BattleProposalFeature.self) diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift b/Projects/Feature/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift similarity index 87% rename from Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift rename to Projects/Feature/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift index fe131c2b..00469136 100644 --- a/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift +++ b/Projects/Feature/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift @@ -4,12 +4,10 @@ // import Foundation +import PickeCoreLogger import ProfileDomainInterface import ComposableArchitecture -import Entity -import LogMacro -import UseCase @Reducer public struct BattleRecordFeature { @@ -19,7 +17,13 @@ public struct BattleRecordFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var isLoadingMore: Bool = false public var items: [BattleRecord] = [] public var nextOffset: Int = 0 @@ -101,7 +105,7 @@ extension BattleRecordFeature { return .send(.delegate(.dismiss)) case .reachedBottom: - guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + guard state.hasNext, !state.isLoadingMore, state.viewState != .loading else { return .none } return .send(.async(.fetch(reset: false))) case let .recordTapped(record): @@ -116,7 +120,7 @@ extension BattleRecordFeature { switch action { case let .fetch(reset): if reset { - state.isLoading = true + state.viewState = .loading } else { state.isLoadingMore = true } @@ -138,7 +142,7 @@ extension BattleRecordFeature { ) -> Effect { switch action { case let .recordsResponse(result, reset): - state.isLoading = false + state.viewState = .loaded state.isLoadingMore = false switch result { case let .success(page): @@ -150,7 +154,7 @@ extension BattleRecordFeature { state.nextOffset = page.nextOffset state.hasNext = page.hasNext case let .failure(error): - Log.error("[BattleRecordFeature] fetchBattleRecords failed: \(error.localizedDescription)") + PickeLogger.error("[BattleRecordFeature] fetchBattleRecords failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift b/Projects/Feature/Profile/Sources/BattleRecord/View/BattleRecordView.swift similarity index 95% rename from Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift rename to Projects/Feature/Profile/Sources/BattleRecord/View/BattleRecordView.swift index 177b1fd0..9f9e5cc1 100644 --- a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift +++ b/Projects/Feature/Profile/Sources/BattleRecord/View/BattleRecordView.swift @@ -7,8 +7,9 @@ import SwiftUI import ComposableArchitecture import PickeDesignKit -import Entity -import Utill +import PickeSharedUI +import PickeCoreUtility +import ProfileDomainInterface @ViewAction(for: BattleRecordFeature.self) public struct BattleRecordView: View { @@ -26,7 +27,7 @@ public struct BattleRecordView: View { ) .foregroundStyle(.gray500) - if store.isLoading { + if store.viewState == .loading { BattleRecordSkeletonView() } else { content() diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift b/Projects/Feature/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift similarity index 95% rename from Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift rename to Projects/Feature/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift index a6301c32..b97ad972 100644 --- a/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift +++ b/Projects/Feature/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift @@ -41,7 +41,7 @@ struct BattleRecordSkeletonView: View { maxWidth: Bool = false, height: CGFloat ) -> some View { - SkeletonBlock(cornerRadius: 4, tone: .light) + SkeletonView(.round(cornerRadius: 4)) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift b/Projects/Feature/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift similarity index 88% rename from Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift rename to Projects/Feature/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift index 1a98a516..45378760 100644 --- a/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift +++ b/Projects/Feature/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift @@ -4,12 +4,10 @@ // import Foundation +import PickeCoreLogger import ProfileDomainInterface import ComposableArchitecture -import Entity -import LogMacro -import UseCase @Reducer public struct ContentActivityFeature { @@ -20,7 +18,13 @@ public struct ContentActivityFeature { @ObservableState public struct State: Equatable { public var selectedTab: ContentActivityType = .comment - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var isLoadingMore: Bool = false public var items: [ContentActivity] = [] public var nextOffset: Int = 0 @@ -108,7 +112,7 @@ extension ContentActivityFeature { return .send(.async(.fetch(reset: true))) case .reachedBottom: - guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + guard state.hasNext, !state.isLoadingMore, state.viewState != .loading else { return .none } return .send(.async(.fetch(reset: false))) } } @@ -120,7 +124,7 @@ extension ContentActivityFeature { switch action { case let .fetch(reset): if reset { - state.isLoading = true + state.viewState = .loading } else { state.isLoadingMore = true } @@ -147,7 +151,7 @@ extension ContentActivityFeature { ) -> Effect { switch action { case let .activitiesResponse(result, reset): - state.isLoading = false + state.viewState = .loaded state.isLoadingMore = false switch result { case let .success(page): @@ -159,7 +163,7 @@ extension ContentActivityFeature { state.nextOffset = page.nextOffset state.hasNext = page.hasNext case let .failure(error): - Log.error("[ContentActivityFeature] fetchContentActivities failed: \(error.localizedDescription)") + PickeLogger.error("[ContentActivityFeature] fetchContentActivities failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift b/Projects/Feature/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift similarity index 92% rename from Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift rename to Projects/Feature/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift index 12997053..a1d1be20 100644 --- a/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift +++ b/Projects/Feature/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift @@ -14,7 +14,7 @@ struct ContentActivitySkeletonView: View { ForEach(0 ..< 5, id: \.self) { _ in VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { - SkeletonBlock(cornerRadius: 18, tone: .light) + SkeletonView(.round(cornerRadius: 18)) .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 4) { block(width: 120, height: 14) @@ -49,7 +49,7 @@ struct ContentActivitySkeletonView: View { maxWidth: Bool = false, height: CGFloat ) -> some View { - SkeletonBlock(cornerRadius: 4, tone: .light) + SkeletonView(.round(cornerRadius: 4)) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift b/Projects/Feature/Profile/Sources/ContentActivity/View/ContentActivityView.swift similarity index 96% rename from Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift rename to Projects/Feature/Profile/Sources/ContentActivity/View/ContentActivityView.swift index 40f1a0f7..620f8812 100644 --- a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift +++ b/Projects/Feature/Profile/Sources/ContentActivity/View/ContentActivityView.swift @@ -6,10 +6,10 @@ import SwiftUI import ComposableArchitecture -import Entity -import Kingfisher +import PickeCoreUtility import PickeDesignKit -import Utill +import PickeSharedUI +import ProfileDomainInterface @ViewAction(for: ContentActivityFeature.self) public struct ContentActivityView: View { @@ -30,7 +30,7 @@ public struct ContentActivityView: View { tabBar() Group { - if store.isLoading { + if store.viewState == .loading { ContentActivitySkeletonView() } else { content() @@ -187,9 +187,8 @@ private extension ContentActivityView { // 디자인(Z5YAW): 항상 beige600 원 배경 위에 캐릭터/기본 아이콘. ZStack { if !author.characterImageURL.isEmpty, let url = URL(string: author.characterImageURL) { - KFImage(url) - .resizable() - .scaledToFit() + PickeRemoteImage(url: url) { EmptyView() } + .content(.fit) .padding(3) } else { Image(systemName: "cat.fill") diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Feature/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift similarity index 100% rename from Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift rename to Projects/Feature/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Feature/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift similarity index 100% rename from Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift rename to Projects/Feature/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift similarity index 93% rename from Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift rename to Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift index 09005caf..b8be8465 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -4,14 +4,15 @@ // import Foundation +import PickeCoreLogger import ProfileDomainInterface import ComposableArchitecture -import Entity -import LogMacro import NotificationDomainInterface import PickeDesignKit -import UseCase +import PickeSharedUI +import AdInterface +import PickeAnalyticsInterface @Reducer public struct ProfileFeature { @@ -33,7 +34,13 @@ public struct ProfileFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded /// 닉네임 — /me/mypage 응답에서 주입. public var nickname: String = "" /// 사용자 코드 (앞에 `@` 표기) — /me/mypage 응답에서 주입. @@ -94,6 +101,7 @@ public struct ProfileFeature { case freeChargeTapped case philosopherTapped case menuTapped(MenuItem) + case adNativeClicked } public enum AsyncAction: Equatable { @@ -227,6 +235,10 @@ extension ProfileFeature { case let .menuTapped(item): return .send(.delegate(.menuSelected(item))) + + case .adNativeClicked: + analyticsUseCase.track(.adClick(AdClickData(placement: .mypage, format: .native, unit: "ADFIT_NATIVE_2_1"))) + return .none } } @@ -236,7 +248,7 @@ extension ProfileFeature { ) -> Effect { switch action { case .fetchProfile: - state.isLoading = true + state.viewState = .loading return .run { [useCase = profileUseCase] send in let result = await Result { try await useCase.fetchMyPage() @@ -261,7 +273,7 @@ extension ProfileFeature { ) -> Effect { switch action { case let .myPageResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(myPage): state.nickname = myPage.profile.nickname @@ -272,7 +284,7 @@ extension ProfileFeature { state.philosopherImageURL = myPage.philosopher.imageURL.isEmpty ? nil : myPage.philosopher.imageURL state.profileImageURL = myPage.profile.characterImageURL.isEmpty ? nil : myPage.profile.characterImageURL case let .failure(error): - Log.error("[ProfileFeature] fetchMyPage failed: \(error.localizedDescription)") + PickeLogger.error("[ProfileFeature] fetchMyPage failed: \(error.localizedDescription)", category: .ui) } return .none diff --git a/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift b/Projects/Feature/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift similarity index 96% rename from Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift rename to Projects/Feature/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift index 280dc2db..c516ea9c 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift +++ b/Projects/Feature/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift @@ -51,7 +51,7 @@ struct ProfileSkeletonView: View { height: CGFloat, radius: CGFloat = 4 ) -> some View { - SkeletonBlock(cornerRadius: radius, tone: .light) + SkeletonView(.round(cornerRadius: radius)) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Feature/Profile/Sources/Main/View/ProfileView.swift similarity index 95% rename from Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift rename to Projects/Feature/Profile/Sources/Main/View/ProfileView.swift index 474b6a88..32fa4ef0 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Feature/Profile/Sources/Main/View/ProfileView.swift @@ -5,10 +5,10 @@ import SwiftUI -import AdKit +import FeatureSharedUI import ComposableArchitecture -import Kingfisher import PickeDesignKit +import PickeSharedUI @ViewAction(for: ProfileFeature.self) public struct ProfileView: View { @@ -22,7 +22,7 @@ public struct ProfileView: View { VStack(spacing: 0) { topBar() - if store.isLoading { + if store.viewState == .loading { ProfileSkeletonView() } else { // xr63n: 카드 그룹 ↔ 메뉴 그룹 gap 20 @@ -44,7 +44,8 @@ public struct ProfileView: View { // 마이페이지 하단 네이티브 광고 — 2:1(.wide) 규격, 좌우 여백 16. AdFitNativeAdView( unit: .wide, - insets: EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) + insets: EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16), + onAdClick: { send(.adNativeClicked) } ) } } @@ -126,9 +127,8 @@ private extension ProfileView { // 디자인(oFtBQ): 항상 beige600 원 배경 위에 캐릭터 이미지/기본 아이콘을 올린다. ZStack { if let urlString = store.profileImageURL, let url = URL(string: urlString) { - KFImage(url) - .resizable() - .scaledToFit() + PickeRemoteImage(url: url) { EmptyView() } + .content(.fit) .padding(4) } else { Image(systemName: "cat.fill") @@ -205,9 +205,8 @@ private extension ProfileView { .scaledToFit() .frame(width: 20, height: 20) } else if let urlString = store.philosopherImageURL, let url = URL(string: urlString) { - KFImage(url) - .resizable() - .scaledToFit() + PickeRemoteImage(url: url) { EmptyView() } + .content(.fit) .padding(4) } else { Image(systemName: "brain.head.profile") diff --git a/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift b/Projects/Feature/Profile/Sources/Notice/Reducer/NoticeFeature.swift similarity index 91% rename from Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift rename to Projects/Feature/Profile/Sources/Notice/Reducer/NoticeFeature.swift index bad59306..7756e5fd 100644 --- a/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift +++ b/Projects/Feature/Profile/Sources/Notice/Reducer/NoticeFeature.swift @@ -4,11 +4,11 @@ // import Foundation +import PickeCoreLogger import ComposableArchitecture -import Entity -import LogMacro import NotificationDomainInterface +import ProfileDomainInterface @Reducer public struct NoticeFeature { @@ -22,7 +22,13 @@ public struct NoticeFeature { public var noticeItems: [NotificationItem] = [] public var eventItems: [NotificationItem] = [] - public var isLoading = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded /// 선택된 항목 — nil 이 아니면 상세 콘텐츠 표시. public var selectedItem: NotificationItem? @@ -100,7 +106,7 @@ extension NoticeFeature { switch action { case .onAppear: guard state.noticeItems.isEmpty, state.eventItems.isEmpty else { return .none } - state.isLoading = true + state.viewState = .loading return .send(.async(.fetchLists)) case .backTapped: @@ -144,7 +150,7 @@ extension NoticeFeature { let (noticePage, eventPage) = try await (notices, events) await send(.inner(.listsResponse(notices: noticePage.items, events: eventPage.items))) } catch { - Log.error("[NoticeFeature] fetchLists failed: \(error.localizedDescription)") + PickeLogger.error("[NoticeFeature] fetchLists failed: \(error.localizedDescription)", category: .ui) await send(.inner(.listsFailed)) } } @@ -162,13 +168,13 @@ extension NoticeFeature { ) -> Effect { switch action { case let .listsResponse(notices, events): - state.isLoading = false + state.viewState = .loaded state.noticeItems = notices state.eventItems = events return .none case .listsFailed: - state.isLoading = false + state.viewState = .loaded return .none } } diff --git a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift b/Projects/Feature/Profile/Sources/Notice/View/NoticeView.swift similarity index 97% rename from Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift rename to Projects/Feature/Profile/Sources/Notice/View/NoticeView.swift index dadd469d..39b60f90 100644 --- a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift +++ b/Projects/Feature/Profile/Sources/Notice/View/NoticeView.swift @@ -6,10 +6,11 @@ import SwiftUI import ComposableArchitecture -import Entity import NotificationDomainInterface import PickeDesignKit -import Utill +import PickeSharedUI +import PickeCoreUtility +import ProfileDomainInterface @ViewAction(for: NoticeFeature.self) public struct NoticeView: View { @@ -63,7 +64,7 @@ private extension NoticeView { @ViewBuilder func listContent() -> some View { Group { - if store.isLoading { + if store.viewState == .loading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if store.currentItems.isEmpty { diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift b/Projects/Feature/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift similarity index 89% rename from Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift rename to Projects/Feature/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift index ec12ef52..787a87ae 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift +++ b/Projects/Feature/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift @@ -4,12 +4,10 @@ // import Foundation +import PickeCoreLogger import ProfileDomainInterface import ComposableArchitecture -import Entity -import LogMacro -import UseCase @Reducer public struct NotificationSettingFeature { @@ -17,7 +15,13 @@ public struct NotificationSettingFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var settings: NotificationSettings = .init() public init() {} @@ -107,7 +111,7 @@ extension NotificationSettingFeature { ) -> Effect { switch action { case .fetch: - state.isLoading = true + state.viewState = .loading return .run { [useCase = profileUseCase] send in let result = await Result { try await useCase.fetchNotificationSettings() @@ -135,12 +139,12 @@ extension NotificationSettingFeature { ) -> Effect { switch action { case let .settingsResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(settings): state.settings = settings case let .failure(error): - Log.error("[NotificationSettingFeature] settings request failed: \(error.localizedDescription)") + PickeLogger.error("[NotificationSettingFeature] settings request failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift b/Projects/Feature/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift similarity index 91% rename from Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift rename to Projects/Feature/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift index a9b9d233..508dc3bc 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift +++ b/Projects/Feature/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift @@ -25,7 +25,7 @@ struct NotificationSettingSkeletonView: View { block(width: 180, height: 11) } Spacer(minLength: 8) - SkeletonBlock(cornerRadius: 9, tone: .light) + SkeletonView(.round(cornerRadius: 9)) .frame(width: 32, height: 18) } .padding(.vertical, 16) @@ -42,7 +42,7 @@ struct NotificationSettingSkeletonView: View { @ViewBuilder private func block(width: CGFloat, height: CGFloat) -> some View { - SkeletonBlock(cornerRadius: 4, tone: .light) + SkeletonView(.round(cornerRadius: 4)) .frame(width: width, height: height) } } diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift b/Projects/Feature/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift similarity index 97% rename from Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift rename to Projects/Feature/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift index 59524663..ef05d10b 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift +++ b/Projects/Feature/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift @@ -7,7 +7,7 @@ import SwiftUI import ComposableArchitecture import PickeDesignKit -import Entity +import ProfileDomainInterface @ViewAction(for: NotificationSettingFeature.self) public struct NotificationSettingView: View { @@ -25,7 +25,7 @@ public struct NotificationSettingView: View { ) .foregroundStyle(.gray500) - if store.isLoading { + if store.viewState == .loading { NotificationSettingSkeletonView() } else { ScrollView { diff --git a/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift b/Projects/Feature/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift similarity index 89% rename from Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift rename to Projects/Feature/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift index ff27fa6f..8b0eb16d 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift +++ b/Projects/Feature/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift @@ -4,13 +4,12 @@ // import Foundation +import PickeCoreLogger import ProfileDomainInterface import ComposableArchitecture -import Entity -import LogMacro import PickeDesignKit -import UseCase +import PickeSharedUI @Reducer public struct PointHistoryFeature { @@ -20,7 +19,13 @@ public struct PointHistoryFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var isLoadingMore: Bool = false public var items: [CreditHistoryItem] = [] public var nextOffset: Int = 0 @@ -115,7 +120,7 @@ extension PointHistoryFeature { return .send(.delegate(.dismiss)) case .reachedBottom: - guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + guard state.hasNext, !state.isLoadingMore, state.viewState != .loading else { return .none } return .send(.async(.fetch(reset: false))) case .suggestTopicTapped: @@ -131,7 +136,7 @@ extension PointHistoryFeature { switch action { case let .fetch(reset): if reset { - state.isLoading = true + state.viewState = .loading } else { state.isLoadingMore = true } @@ -153,7 +158,7 @@ extension PointHistoryFeature { ) -> Effect { switch action { case let .historyResponse(result, reset): - state.isLoading = false + state.viewState = .loaded state.isLoadingMore = false switch result { case let .success(page): @@ -165,7 +170,7 @@ extension PointHistoryFeature { state.nextOffset = page.nextOffset state.hasNext = page.hasNext case let .failure(error): - Log.error("[PointHistoryFeature] fetchCreditHistory failed: \(error.localizedDescription)") + PickeLogger.error("[PointHistoryFeature] fetchCreditHistory failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Feature/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift similarity index 95% rename from Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift rename to Projects/Feature/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift index fb5d5b2b..0a7d7091 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift +++ b/Projects/Feature/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -40,7 +40,7 @@ struct PointHistorySkeletonView: View { @ViewBuilder private func block(width: CGFloat, height: CGFloat) -> some View { - SkeletonBlock(cornerRadius: 4, tone: .light) + SkeletonView(.round(cornerRadius: 4)) .frame(width: width, height: height) } } diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Feature/Profile/Sources/PointHistory/View/PointHistoryView.swift similarity index 96% rename from Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift rename to Projects/Feature/Profile/Sources/PointHistory/View/PointHistoryView.swift index 767b2e49..e0129dbe 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift +++ b/Projects/Feature/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -7,8 +7,9 @@ import SwiftUI import ComposableArchitecture import PickeDesignKit -import Entity -import Utill +import PickeSharedUI +import PickeCoreUtility +import ProfileDomainInterface @ViewAction(for: PointHistoryFeature.self) public struct PointHistoryView: View { @@ -22,7 +23,7 @@ public struct PointHistoryView: View { VStack(spacing: 0) { appBar() - if store.isLoading { + if store.viewState == .loading { PointHistorySkeletonView() } else { content() diff --git a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift b/Projects/Feature/Profile/Sources/Recap/Reducer/RecapFeature.swift similarity index 91% rename from Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift rename to Projects/Feature/Profile/Sources/Recap/Reducer/RecapFeature.swift index 33fec635..3757c73b 100644 --- a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift +++ b/Projects/Feature/Profile/Sources/Recap/Reducer/RecapFeature.swift @@ -4,13 +4,13 @@ // import Foundation +import PickeCoreLogger import ProfileDomainInterface import UIKit import ComposableArchitecture -import Entity -import LogMacro -import UseCase +import PickeAnalyticsInterface +import PickeCoreUtility @Reducer public struct RecapFeature { @@ -21,7 +21,13 @@ public struct RecapFeature { @ObservableState public struct State: Equatable { - public var isLoading: Bool = false + /// 화면이 스켈레톤을 보일지 콘텐츠를 보일지 가르는 상태. + public enum ViewState: Equatable { + case loading + case loaded + } + + public var viewState: ViewState = .loaded public var recap: PhilosopherRecap? /// 애플 시스템 공유 시트 트리거. public var shareItem: ShareItem? @@ -137,7 +143,7 @@ extension RecapFeature { ) -> Effect { switch action { case .fetch: - state.isLoading = true + state.viewState = .loading return .run { [useCase = profileUseCase] send in let result = await Result { try await useCase.fetchRecap() @@ -155,7 +161,7 @@ extension RecapFeature { ) -> Effect { switch action { case let .recapResponse(result): - state.isLoading = false + state.viewState = .loaded switch result { case let .success(recap): state.recap = recap @@ -163,7 +169,7 @@ extension RecapFeature { .reportAction(ReportActionData(actionType: .view, topIndicator: recap.myCard.typeName)) ) case let .failure(error): - Log.error("[RecapFeature] fetchRecap failed: \(error.localizedDescription)") + PickeLogger.error("[RecapFeature] fetchRecap failed: \(error.localizedDescription)", category: .ui) } return .none } diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapLockedView.swift similarity index 98% rename from Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift rename to Projects/Feature/Profile/Sources/Recap/View/Components/RecapLockedView.swift index 895bab1a..c5545480 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapLockedView.swift @@ -6,7 +6,7 @@ import SwiftUI import PickeDesignKit -import Entity +import ProfileDomainInterface struct RecapLockedView: View { /// 잠금 장식용 레이더(블러) — picke.pen 잠금 그래프 라벨/형태(거의 꽉 찬 육각형). diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapMatchCard.swift similarity index 98% rename from Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift rename to Projects/Feature/Profile/Sources/Recap/View/Components/RecapMatchCard.swift index d15b541a..cab63628 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapMatchCard.swift @@ -5,9 +5,9 @@ import SwiftUI -import Entity import Kingfisher import PickeDesignKit +import ProfileDomainInterface public struct RecapMatchCard: View { private let card: RecapCard diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift similarity index 98% rename from Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift rename to Projects/Feature/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift index a1b04053..f46d52a1 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift @@ -5,9 +5,9 @@ import SwiftUI -import Entity import Kingfisher import PickeDesignKit +import ProfileDomainInterface public struct RecapPhilosopherCard: View { private let card: RecapCard diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapRadarChart.swift similarity index 99% rename from Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift rename to Projects/Feature/Profile/Sources/Recap/View/Components/RecapRadarChart.swift index 3d0126c5..c002b65c 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapRadarChart.swift @@ -5,8 +5,8 @@ import SwiftUI -import Entity import PickeDesignKit +import ProfileDomainInterface public struct RecapRadarChart: View { private let axes: [RecapScoreAxis] diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapScoreBar.swift similarity index 97% rename from Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift rename to Projects/Feature/Profile/Sources/Recap/View/Components/RecapScoreBar.swift index 62fe964d..60dd156d 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapScoreBar.swift @@ -6,7 +6,7 @@ import SwiftUI import PickeDesignKit -import Entity +import ProfileDomainInterface public struct RecapScoreBar: View { private let axis: RecapScoreAxis diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift similarity index 87% rename from Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift rename to Projects/Feature/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift index be048720..10575887 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift @@ -16,7 +16,7 @@ struct RecapSkeletonView: View { VStack(spacing: 16) { block(width: 100, height: 13) block(width: 120, height: 24) - SkeletonBlock(cornerRadius: 34, tone: .light).frame(width: 68, height: 68) + SkeletonView(.round(cornerRadius: 34)).frame(width: 68, height: 68) block(width: nil, maxWidth: true, height: 40) HStack(spacing: 8) { ForEach(0 ..< 3, id: \.self) { _ in block(width: 56, height: 20) } @@ -30,7 +30,7 @@ struct RecapSkeletonView: View { block(width: 70, height: 13) cardBox { VStack(spacing: 12) { - SkeletonBlock(cornerRadius: 8, tone: .light).frame(height: 160) + SkeletonView(.round(cornerRadius: 8)).frame(height: 160) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { ForEach(0 ..< 6, id: \.self) { _ in block(width: nil, maxWidth: true, height: 28) } } @@ -65,7 +65,7 @@ struct RecapSkeletonView: View { cardBox { VStack(spacing: 8) { block(width: 40, height: 12) - SkeletonBlock(cornerRadius: 20, tone: .light).frame(width: 40, height: 40) + SkeletonView(.round(cornerRadius: 20)).frame(width: 40, height: 40) block(width: 60, height: 13) block(width: nil, maxWidth: true, height: 22) } @@ -75,7 +75,7 @@ struct RecapSkeletonView: View { } } - SkeletonBlock(cornerRadius: .radiusDefault, tone: .light).frame(height: 52) + SkeletonView(.round(cornerRadius: .radiusDefault)).frame(height: 52) } .padding(.top, 20) .padding(.horizontal, 16) @@ -93,7 +93,7 @@ struct RecapSkeletonView: View { @ViewBuilder private func block(width: CGFloat?, maxWidth: Bool = false, height: CGFloat) -> some View { - SkeletonBlock(cornerRadius: 4, tone: .light) + SkeletonView(.round(cornerRadius: 4)) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) diff --git a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift b/Projects/Feature/Profile/Sources/Recap/View/RecapView.swift similarity index 99% rename from Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift rename to Projects/Feature/Profile/Sources/Recap/View/RecapView.swift index 3e646631..5dab3b9c 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift +++ b/Projects/Feature/Profile/Sources/Recap/View/RecapView.swift @@ -6,9 +6,9 @@ import SwiftUI import ComposableArchitecture -import Entity import Kingfisher import PickeDesignKit +import ProfileDomainInterface @ViewAction(for: RecapFeature.self) public struct RecapView: View { diff --git a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift b/Projects/Feature/Profile/Sources/Settings/Reducer/SettingsFeature.swift similarity index 92% rename from Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift rename to Projects/Feature/Profile/Sources/Settings/Reducer/SettingsFeature.swift index 859e902c..36985d0e 100644 --- a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift +++ b/Projects/Feature/Profile/Sources/Settings/Reducer/SettingsFeature.swift @@ -4,13 +4,16 @@ // import Foundation +import PickeCoreLogger import AuthDomainInterface import ComposableArchitecture -import Entity -import LogMacro import PickeDesignKit -import UseCase +import PickeSharedUI +import PickeAnalyticsInterface +import DeviceServiceInterface +import PickeAuthInterface +import PickeStorageInterface @Reducer public struct SettingsFeature { @@ -88,7 +91,7 @@ public struct SettingsFeature { } @Dependency(\.authUseCase) private var authUseCase - @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.authService) private var authService @Dependency(\.deviceUseCase) private var deviceUseCase @Dependency(\.analyticsUseCase) private var analyticsUseCase @@ -166,7 +169,7 @@ extension SettingsFeature { do { _ = try await authUseCase.logout() } catch { - Log.error("[SettingsFeature] logout failed: \(error.localizedDescription)") + PickeLogger.error("[SettingsFeature] logout failed: \(error.localizedDescription)", category: .ui) } await send(.inner(.sessionCleared)) } @@ -181,11 +184,14 @@ extension SettingsFeature { switch action { case .sessionCleared: state.isProcessing = false - // 서버 호출 성공/실패와 무관하게 로컬 세션은 정리하고 로그인으로 전환. - keychainManager.clear() // 계정 분리 — Mixpanel distinct_id/슈퍼프로퍼티 초기화(다음 유저와 혼선 방지). analyticsUseCase.reset() - return .send(.delegate(.sessionEnded)) + // 서버 호출 성공/실패와 무관하게 로컬 세션은 정리하고 로그인으로 전환. + let authService = authService + return .run { send in + await authService.signOut() + await send(.delegate(.sessionEnded)) + } } } diff --git a/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift b/Projects/Feature/Profile/Sources/Settings/View/SettingsView.swift similarity index 98% rename from Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift rename to Projects/Feature/Profile/Sources/Settings/View/SettingsView.swift index 9a9e7c11..334782b1 100644 --- a/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift +++ b/Projects/Feature/Profile/Sources/Settings/View/SettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture import PickeDesignKit +import PickeSharedUI @ViewAction(for: SettingsFeature.self) public struct SettingsView: View { diff --git a/Projects/Presentation/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift b/Projects/Feature/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift similarity index 92% rename from Projects/Presentation/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift rename to Projects/Feature/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift index 60939cc3..785b54a1 100644 --- a/Projects/Presentation/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift +++ b/Projects/Feature/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift @@ -4,12 +4,15 @@ // import Foundation +import PickeCoreLogger import AuthDomainInterface import ComposableArchitecture -import LogMacro import PickeDesignKit -import UseCase +import PickeSharedUI +import DeviceServiceInterface +import PickeAuthInterface +import PickeStorageInterface @Reducer public struct WithdrawReasonFeature { @@ -90,7 +93,7 @@ public struct WithdrawReasonFeature { } @Dependency(\.authUseCase) private var authUseCase - @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.authService) private var authService @Dependency(\.deviceUseCase) private var deviceUseCase public var body: some Reducer { @@ -182,7 +185,7 @@ extension WithdrawReasonFeature { do { _ = try await authUseCase.withDraw(reason: reason) } catch { - Log.error("[WithdrawReasonFeature] withdraw failed: \(error.localizedDescription)") + PickeLogger.error("[WithdrawReasonFeature] withdraw failed: \(error.localizedDescription)", category: .ui) } await send(.inner(.sessionCleared)) } @@ -198,8 +201,11 @@ extension WithdrawReasonFeature { case .sessionCleared: state.isProcessing = false // 서버 호출 성공/실패와 무관하게 로컬 세션은 정리하고 로그인으로 전환. - keychainManager.clear() - return .send(.delegate(.sessionEnded)) + let authService = authService + return .run { send in + await authService.signOut() + await send(.delegate(.sessionEnded)) + } } } } diff --git a/Projects/Presentation/Profile/Sources/Withdraw/View/WithdrawReasonView.swift b/Projects/Feature/Profile/Sources/Withdraw/View/WithdrawReasonView.swift similarity index 99% rename from Projects/Presentation/Profile/Sources/Withdraw/View/WithdrawReasonView.swift rename to Projects/Feature/Profile/Sources/Withdraw/View/WithdrawReasonView.swift index 3d461157..e102224a 100644 --- a/Projects/Presentation/Profile/Sources/Withdraw/View/WithdrawReasonView.swift +++ b/Projects/Feature/Profile/Sources/Withdraw/View/WithdrawReasonView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture import PickeDesignKit +import PickeSharedUI @ViewAction(for: WithdrawReasonFeature.self) public struct WithdrawReasonView: View { diff --git a/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift b/Projects/Feature/Profile/Tests/Sources/ProfileTests.swift similarity index 83% rename from Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift rename to Projects/Feature/Profile/Tests/Sources/ProfileTests.swift index f3758250..5b2999f7 100644 --- a/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift +++ b/Projects/Feature/Profile/Tests/Sources/ProfileTests.swift @@ -1,6 +1,6 @@ // // ProfileTests.swift -// Presentation.ProfileTests +// Feature.ProfileTests // // Created by Roy on 2026-06-08. // @@ -17,7 +17,6 @@ struct ProfileTests { @Test func profileLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Presentation/Web/Interface/Sources/WebInterface.swift b/Projects/Feature/Web/Interface/Sources/WebInterface.swift similarity index 100% rename from Projects/Presentation/Web/Interface/Sources/WebInterface.swift rename to Projects/Feature/Web/Interface/Sources/WebInterface.swift diff --git a/Projects/Presentation/Web/Project.swift b/Projects/Feature/Web/Project.swift similarity index 59% rename from Projects/Presentation/Web/Project.swift rename to Projects/Feature/Web/Project.swift index 31fb22c0..f21ea852 100644 --- a/Projects/Presentation/Web/Project.swift +++ b/Projects/Feature/Web/Project.swift @@ -1,15 +1,21 @@ import Foundation -import ProjectDescription + +import DependencyPackagePlugin import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin -let project = Project.configure( - moduleType: .feature(.Web), +import ProjectDescription + +let project = Project.makeModule( + name: "Web", bundleId: .appBundleID(name: ".Web"), + product: .staticFramework, settings: .settings(), dependencies: [ + .ui(.designKit), .SPM.composableArchitecture, - .Shared(implements: .Shared), - ] -) + ], + hasTests: true, + hasInterface: true, + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Presentation/Web/Sources/Reducer/WebReducer.swift b/Projects/Feature/Web/Sources/Reducer/WebReducer.swift similarity index 100% rename from Projects/Presentation/Web/Sources/Reducer/WebReducer.swift rename to Projects/Feature/Web/Sources/Reducer/WebReducer.swift diff --git a/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift b/Projects/Feature/Web/Sources/View/WebRepresentableView.swift similarity index 100% rename from Projects/Presentation/Web/Sources/View/WebRepresentableView.swift rename to Projects/Feature/Web/Sources/View/WebRepresentableView.swift diff --git a/Projects/Presentation/Web/Sources/View/WebView.swift b/Projects/Feature/Web/Sources/View/WebView.swift similarity index 100% rename from Projects/Presentation/Web/Sources/View/WebView.swift rename to Projects/Feature/Web/Sources/View/WebView.swift diff --git a/Projects/Presentation/Web/Tests/Sources/WebTests.swift b/Projects/Feature/Web/Tests/Sources/WebTests.swift similarity index 84% rename from Projects/Presentation/Web/Tests/Sources/WebTests.swift rename to Projects/Feature/Web/Tests/Sources/WebTests.swift index 5c330c51..c7906567 100644 --- a/Projects/Presentation/Web/Tests/Sources/WebTests.swift +++ b/Projects/Feature/Web/Tests/Sources/WebTests.swift @@ -1,6 +1,6 @@ // // WebTests.swift -// Presentation.WebTests +// Feature.WebTests // // Created by Roy on 2026-06-04. // @@ -18,7 +18,6 @@ struct WebTests { @Test func webLogicTest() { - // Add your test logic here. let result = true #expect(result == true) } diff --git a/Projects/Network/NetworkHeader/Project.swift b/Projects/Network/NetworkHeader/Project.swift deleted file mode 100644 index dad486c4..00000000 --- a/Projects/Network/NetworkHeader/Project.swift +++ /dev/null @@ -1,18 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "NetworkHeader"), - bundleId: .appBundleID(name: ".NetworkHeader"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Network(implements: .NetworkToken), - .SPM.alamofire, - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Network/NetworkHeader/Sources/APIHeader.swift b/Projects/Network/NetworkHeader/Sources/APIHeader.swift deleted file mode 100644 index 3202182b..00000000 --- a/Projects/Network/NetworkHeader/Sources/APIHeader.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// APIHeader.swift -// NetworkHeader -// -// Created by Wonji Suh on 5/7/25. -// - -import Foundation -import NetworkToken -import WeaveDI - -public struct APIHeader { - public static let contentType = "Content-Type" - public static let accessToken = "Authorization" - public static let refreshToken = "X-Refresh-Token" - public static let accept = "accept" - - @Dependency(\.tokenProvider) private static var tokenProvider - - public static var accessTokenKeyChain: String { - get { - let token = tokenProvider.accessToken() ?? "" - return token - } - set { updateAccessToken(newValue) } - } - - public static func updateAccessToken(_ token: String?) { - guard let newToken = token, !newToken.isEmpty else { - tokenProvider.clearAccessToken() - return - } - tokenProvider.saveAccessToken(newToken) - } - - public init() {} -} - -public extension APIHeader { - internal static func baseHeaders(_ headers: [String: String]?) -> [String: String] { - var baseHeaders = baseHeader - if let headers { - baseHeaders.merge(headers) { $1 } - } - return baseHeaders - } - - static var baseHeader: [String: String] { - [ - contentType: APIHeaderManger.contentType, - accessToken: "Bearer \(accessTokenKeyChain)", - accept: APIHeaderManger.contentType, - ] - } - - static var notAccessTokenHeader: [String: String] { - [ - contentType: APIHeaderManger.contentType, - accept: APIHeaderManger.contentType, - ] - } - - static var mutiPartbaseHeader: [String: String] { - [ - contentType: APIHeaderManger.multipartContentType, - accessToken: "Bearer \(accessTokenKeyChain)", - ] - } - - static var applebaseHeader: [String: String] { - [ - contentType: APIHeaderManger.contentType, - accept: APIHeaderManger.contentType, - ] - } -} diff --git a/Projects/Network/NetworkHeader/Sources/APIHeaderManger.swift b/Projects/Network/NetworkHeader/Sources/APIHeaderManger.swift deleted file mode 100644 index 445a4014..00000000 --- a/Projects/Network/NetworkHeader/Sources/APIHeaderManger.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// APIHeaderManger.swift -// NetworkHeader -// -// Created by Wonji Suh on 5/7/25. -// - -import Foundation - -public enum APIHeaderManger { - - static let appPackageName: String = "-" - static let contentType: String = "application/json" - static let multipartContentType: String = "multipart/form-data" - static let contentAppleType: String = "application/x-www-form-urlencoded" - static let csrf: String = "BNazqxDLBzmlYFKCwAMMJYNcmkAq6kAt" -} diff --git a/Projects/Network/NetworkHeader/Sources/PickeTargetType.swift b/Projects/Network/NetworkHeader/Sources/PickeTargetType.swift deleted file mode 100644 index 82206e40..00000000 --- a/Projects/Network/NetworkHeader/Sources/PickeTargetType.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// PickeTargetType.swift -// NetworkHeader -// - -import Foundation - -@_exported import Alamofire - -/// 엔드포인트의 도메인(base URL + 도메인 경로). -public protocol PickeDomainType { - var baseURLString: String { get } - var url: String { get } -} - -/// feature Service 가 conform 하는 요청 스펙. Moya 비의존. -public protocol PickeTargetType { - associatedtype Domain: PickeDomainType - var domain: Domain { get } - var urlPath: String { get } - var method: HTTPMethod { get } - var parameters: [String: Any]? { get } - var headers: [String: String]? { get } - /// 파라미터 인코딩. 기본은 method 기준(아래 default). 케이스별로 달라야 하는 Service 는 재정의한다. - var parameterEncoding: ParameterEncoding { get } -} - -public extension PickeTargetType { - /// 기본 헤더(APIHeader.baseHeader). Service 가 재정의 가능. - var headers: [String: String]? { APIHeader.baseHeader } - - /// 동작 보존: AsyncMoya BaseTargetType 은 GET 만 쿼리스트링, 그 외(POST/PATCH/DELETE)는 JSON 바디로 - /// 인코딩했다(예: AuthService.withdraw DELETE + token → JSON body). 그 규칙을 기본값으로 따른다. - /// (DeviceService.unregister 처럼 DELETE 인데 쿼리스트링이 필요한 케이스는 이 프로퍼티를 재정의) - var parameterEncoding: ParameterEncoding { - (method == .get) ? URLEncoding.queryString : JSONEncoding.default - } - - /// Alamofire 로 전송 가능한 `URLRequest` 생성. Moya `Endpoint` 를 대체. - /// 인코딩 위치는 method 기준(GET/DELETE 쿼리스트링, 그 외 JSON 바디) — Joongna JNNetwork 와 동일. - func asURLRequest() throws -> URLRequest { - guard let base = URL(string: domain.baseURLString) else { - throw PickeNetworkError.invalidBaseURL(domain.baseURLString) - } - let fullURL = base.appendingPathComponent(domain.url + urlPath) - var request = try URLRequest( - url: fullURL, - method: method, - headers: headers.map { HTTPHeaders($0) } - ) - if let parameters { - request = try parameterEncoding.encode(request, with: parameters) - } - return request - } -} - -public enum PickeNetworkError: Error { - case invalidBaseURL(String) - case emptyData - case statusCode(Int, Data) -} diff --git a/Projects/Network/NetworkModule/Project.swift b/Projects/Network/NetworkModule/Project.swift deleted file mode 100644 index f6354437..00000000 --- a/Projects/Network/NetworkModule/Project.swift +++ /dev/null @@ -1,19 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "NetworkModule"), - bundleId: .appBundleID(name: ".NetworkModule"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Network(implements: .NetworkToken), - .Network(implements: .NetworkHeader), - .Network(implements: .Networking), - .Network(implements: .ThirdPartys), - ], - sources: ["Sources/**"] -) diff --git a/Projects/Network/NetworkModule/Sources/Exported/NetworkModuleExported.swift b/Projects/Network/NetworkModule/Sources/Exported/NetworkModuleExported.swift deleted file mode 100644 index 700ac2ca..00000000 --- a/Projects/Network/NetworkModule/Sources/Exported/NetworkModuleExported.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// NetworkExported.swift -// Network -// - -// MARK: - Network 레이어 한번에 노출 - -@_exported import NetworkToken -@_exported import NetworkHeader -@_exported import Networking -@_exported import ThirdPartys diff --git a/Projects/Network/NetworkToken/Project.swift b/Projects/Network/NetworkToken/Project.swift deleted file mode 100644 index 69050162..00000000 --- a/Projects/Network/NetworkToken/Project.swift +++ /dev/null @@ -1,18 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "NetworkToken"), - bundleId: .appBundleID(name: ".NetworkToken"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .SPM.weaveDI, - .SPM.dependencies, - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Network/NetworkToken/Sources/TokenProviding.swift b/Projects/Network/NetworkToken/Sources/TokenProviding.swift deleted file mode 100644 index 8ecac19c..00000000 --- a/Projects/Network/NetworkToken/Sources/TokenProviding.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// TokenProviding.swift -// NetworkToken -// -// Created by Wonji Suh on 1/2/26. -// - -import Foundation - -import Dependencies -import WeaveDI - -public protocol TokenProviding: Sendable { - func accessToken() -> String? - func saveAccessToken(_ token: String) - func clearAccessToken() -} - -private enum TokenProviderKey: DependencyKey { - static var liveValue: TokenProviding { - UnifiedDI.resolve(TokenProviding.self) ?? InMemoryTokenProvider() - } -} - -public extension DependencyValues { - var tokenProvider: TokenProviding { - get { self[TokenProviderKey.self] } - set { self[TokenProviderKey.self] = newValue } - } -} - -public final class InMemoryTokenProvider: TokenProviding, @unchecked Sendable { - private var storage: String? - private let lock = NSLock() - - public init() {} - - public func accessToken() -> String? { - lock.lock() - defer { lock.unlock() } - return storage - } - - public func saveAccessToken(_ token: String) { - lock.lock() - storage = token - lock.unlock() - } - - public func clearAccessToken() { - lock.lock() - storage = nil - lock.unlock() - } -} diff --git a/Projects/Network/Networking/NetworkingTests/Sources/Test.swift b/Projects/Network/Networking/NetworkingTests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Network/Networking/NetworkingTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Network/Networking/Project.swift b/Projects/Network/Networking/Project.swift deleted file mode 100644 index fa49cdf0..00000000 --- a/Projects/Network/Networking/Project.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.configure( - moduleType: .module(name: "Networking"), - bundleId: .appBundleID(name: ".Networking"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Network(implements: .NetworkHeader) - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Network/Networking/Sources/Exorted/NetworkExported.swift b/Projects/Network/Networking/Sources/Exorted/NetworkExported.swift deleted file mode 100644 index 3ea93584..00000000 --- a/Projects/Network/Networking/Sources/Exorted/NetworkExported.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// NetworkExported.swift -// Network -// -// Created by Wonji Suh on 9/5/25. -// - -@_exported import ThirdPartys diff --git a/Projects/Network/ThirdPartys/Project.swift b/Projects/Network/ThirdPartys/Project.swift deleted file mode 100644 index 301eb54c..00000000 --- a/Projects/Network/ThirdPartys/Project.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.configure( - moduleType: .module(name: "ThirdPartys"), - bundleId: .appBundleID(name: ".ThirdPartys"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .SPM.weaveDI - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Network/ThirdPartys/Sources/ThirdPartysExport.swift b/Projects/Network/ThirdPartys/Sources/ThirdPartysExport.swift deleted file mode 100644 index 79fba269..00000000 --- a/Projects/Network/ThirdPartys/Sources/ThirdPartysExport.swift +++ /dev/null @@ -1,5 +0,0 @@ -// -// ThirdPartys -// -// Created by Wonji Suh on 11/4/24. -// diff --git a/Projects/Network/ThirdPartys/ThirdPartysTests/Sources/.gitkeep b/Projects/Network/ThirdPartys/ThirdPartysTests/Sources/.gitkeep deleted file mode 100644 index a2c133b2..00000000 --- a/Projects/Network/ThirdPartys/ThirdPartysTests/Sources/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# This file keeps the test Sources directory in git \ No newline at end of file diff --git a/Projects/Presentation/Auth/Project.swift b/Projects/Presentation/Auth/Project.swift deleted file mode 100644 index c63eaade..00000000 --- a/Projects/Presentation/Auth/Project.swift +++ /dev/null @@ -1,19 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Auth), - bundleId: .appBundleID(name: ".Auth"), - settings: .settings(), - dependencies: [ - .SPM.composableArchitecture, - .SPM.tcaFlow, - .Domain(.Auth, .interface), - .Domain(implements: .Entity), - .Domain(implements: .UseCase), - .Shared(implements: .Shared), - ] -) diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift deleted file mode 100644 index 9de8f68d..00000000 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// AuthCoordinator.swift -// Auth -// -// Created by Wonji Suh on 5/11/26. -// - -import Foundation - -import AuthInterface -import ComposableArchitecture -import Entity -import TCAFlow - -import AuthDomainInterface - -@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 - routes = [.root(.login(.init(userSession: userSession)), embedInNavigationView: true)] - } - - public init(route: AuthRoute) { - switch route { - case .login: - self.init() - } - } - } - - @CasePathable - public enum Action { - case router(IndexedRouterActionOf) - case view(View) - case async(AsyncAction) - case inner(InnerAction) - case delegate(AuthDelegate) - } - - // MARK: - ViewAction - - @CasePathable - public enum View { - case backAction - case backToRootAction - } - - // MARK: - AsyncAction 비동기 처리 액션 - - public enum AsyncAction: Equatable {} - - // MARK: - 앱내에서 사용하는 액션 - - public enum InnerAction: Equatable {} - - func handleRoute( - state: inout State, - action: Action - ) -> Effect { - switch action { - case let .router(routeAction): - routerAction(state: &state, action: routeAction) - - 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) - } - } -} - -// MARK: - Effect Cancellation IDs - -nonisolated enum AuthCancelID: Hashable { - case loginEffects -} - -extension AuthCoordinator { - private func routerAction( - state: inout State, - action: IndexedRouterActionOf - ) -> Effect { - switch action { - // MARK: - 로그인 성공 → 온보딩 화면 푸시 - - case .routeAction(_, action: .login(.delegate(.presentOnboarding))): - state.routes.push(.onboarding(.init())) - return .none - - // MARK: - 온보딩 완료 → 루트로 (다음 플로우 연결 지점) - - case .routeAction(id: _, action: .login(.delegate(.presentMainTab))): - return .send(.delegate(.presentMainTab)) - - case .routeAction(_, action: .onboarding(.delegate(.presentMainTab))): - return .send(.delegate(.presentMainTab)) - - 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 handleDelegateAction( - state _: inout State, - action: AuthDelegate - ) -> Effect { - switch action { - case .presentMainTab: - return .none - } - } - - private func handleAsyncAction( - state _: inout State, - action _: AsyncAction - ) -> Effect { - .none - } - - private func handleInnerAction( - state _: inout State, - action _: InnerAction - ) -> Effect { - .none - } -} - -// swiftformat:disable extensionAccessControl -extension AuthCoordinator { - @Reducer - public enum AuthScreen { - case login(LoginFeature) - case onboarding(OnBoardingFeature) - } -} - -// swiftformat:enable extensionAccessControl - -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 deleted file mode 100644 index fa0b9195..00000000 --- a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// AuthCoordinatorView.swift -// Auth -// -// Created by Wonji Suh on 5/11/26. -// - -import Foundation - -import SwiftUI - -import ComposableArchitecture -import TCAFlow - -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 let .login(loginStore): - LoginView(store: loginStore) - .navigationBarBackButtonHidden() - - case let .onboarding(onboardingStore): - OnBoardingView(store: onboardingStore) - .navigationBarBackButtonHidden() - } - } - } -} diff --git a/Projects/Presentation/Auth/Testing/Sources/AuthTesting.swift b/Projects/Presentation/Auth/Testing/Sources/AuthTesting.swift deleted file mode 100644 index e372c678..00000000 --- a/Projects/Presentation/Auth/Testing/Sources/AuthTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import AuthInterface - -public enum AuthTesting {} diff --git a/Projects/Presentation/Battle/Project.swift b/Projects/Presentation/Battle/Project.swift deleted file mode 100644 index 526f444e..00000000 --- a/Projects/Presentation/Battle/Project.swift +++ /dev/null @@ -1,19 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Battle), - bundleId: .appBundleID(name: ".Battle"), - settings: .settings(), - dependencies: [ - .Shared(implements: .Shared), - .Domain(.Battle, .interface), - .Domain(implements: .UseCase), - .SPM.composableArchitecture, - .SPM.tcaFlow, - .SPM.kingfisher, - ] -) diff --git a/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift deleted file mode 100644 index d8537328..00000000 --- a/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// BattleCoordinator.swift -// Battle -// - -import Foundation - -import ComposableArchitecture -import TCAFlow - -@FlowCoordinator(screen: "BattleScreen", navigation: true) -public struct BattleCoordinator { - public init() {} - - @ObservableState - public struct State: Equatable { - public var routes: [Route] - - public init() { - routes = [.root(.battle(.init()), embedInNavigationView: true)] - } - } - - @CasePathable - public enum Action { - case router(IndexedRouterActionOf) - case view(View) - case async(AsyncAction) - case inner(InnerAction) - case navigation(NavigationAction) - } - - @CasePathable - public enum View { - case backAction - case backToRootAction - } - - public enum AsyncAction: Equatable {} - public enum InnerAction: Equatable {} - public enum NavigationAction: Equatable {} - - func handleRoute( - state: inout State, - action: Action - ) -> Effect { - switch action { - case let .router(routeAction): - routerAction(state: &state, action: routeAction) - case let .view(viewAction): - handleViewAction(state: &state, action: viewAction) - case .async, .inner, .navigation: - .none - } - } -} - -extension BattleCoordinator { - private func routerAction( - state: inout State, - action: IndexedRouterActionOf - ) -> Effect { - switch action { - 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 - } - } -} - -// swiftformat:disable extensionAccessControl -extension BattleCoordinator { - @Reducer - public enum BattleScreen { - case battle(BattleFeature) - } -} - -// swiftformat:enable extensionAccessControl - -extension BattleCoordinator.BattleScreen.State: Equatable {} diff --git a/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift deleted file mode 100644 index b3f30a3b..00000000 --- a/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// BattleCoordinatorView.swift -// Battle -// - -import Foundation - -import SwiftUI - -import ComposableArchitecture -import TCAFlow - -public struct BattleCoordinatorView: 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 let .battle(battleStore): - BattleView(store: battleStore) - } - } - } -} diff --git a/Projects/Presentation/Battle/Testing/Sources/BattleTesting.swift b/Projects/Presentation/Battle/Testing/Sources/BattleTesting.swift deleted file mode 100644 index d968948f..00000000 --- a/Projects/Presentation/Battle/Testing/Sources/BattleTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import BattleInterface - -public enum BattleTesting {} diff --git a/Projects/Presentation/Chat/Project.swift b/Projects/Presentation/Chat/Project.swift deleted file mode 100644 index 24878ae2..00000000 --- a/Projects/Presentation/Chat/Project.swift +++ /dev/null @@ -1,25 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Chat), - bundleId: .appBundleID(name: ".Chat"), - settings: .settings(), - dependencies: [ - .Domain(.Common, .interface), - .Domain(.Battle, .interface), - .Domain(.Home, .interface), - .Domain(implements: .UseCase), - .Domain(.Comment, .interface), - .Shared(implements: .Shared), - // 큐레이션 리스트 상단 배너 광고 — 광고를 노출하는 화면만 명시적으로 의존한다. - .Shared(implements: .AdKit), - .SPM.composableArchitecture, - .SPM.tcaFlow, - .SPM.kingfisher, - .SPM.logMarco, - ] -) diff --git a/Projects/Presentation/Chat/Testing/Sources/ChatTesting.swift b/Projects/Presentation/Chat/Testing/Sources/ChatTesting.swift deleted file mode 100644 index 7daa43f7..00000000 --- a/Projects/Presentation/Chat/Testing/Sources/ChatTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import ChatInterface - -public enum ChatTesting {} diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift deleted file mode 100644 index 01d5cf2d..00000000 --- a/Projects/Presentation/Hifi/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Hifi), - bundleId: .appBundleID(name: ".Hifi"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Shared(implements: .Shared), - // 탐색 리스트 인라인 배너 광고 — 광고를 노출하는 화면만 명시적으로 의존한다. - .Shared(implements: .AdKit), - .Domain(.Home, .interface), - .Domain(implements: .UseCase), - .Domain(.Search, .interface), - .Domain(.Notification, .interface), - .SPM.composableArchitecture, - .SPM.tcaFlow, - .SPM.kingfisher, - ] -) diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift deleted file mode 100644 index c9d4b52b..00000000 --- a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// HifiCoordinator.swift -// Hifi -// - -import Foundation - -import ComposableArchitecture -import TCAFlow - -@FlowCoordinator(screen: "HifiScreen", navigation: true) -public struct HifiCoordinator { - public init() {} - - @ObservableState - public struct State: Equatable { - public var routes: [Route] - - public init() { - routes = [.root(.hifi(.init()), embedInNavigationView: true)] - } - } - - @CasePathable - public enum Action { - case router(IndexedRouterActionOf) - case view(View) - case async(AsyncAction) - case inner(InnerAction) - case navigation(NavigationAction) - } - - @CasePathable - public enum View { - case backAction - case backToRootAction - } - - public enum AsyncAction: Equatable {} - public enum InnerAction: Equatable {} - public enum NavigationAction: Equatable {} - - func handleRoute( - state: inout State, - action: Action - ) -> Effect { - switch action { - case let .router(routeAction): - routerAction(state: &state, action: routeAction) - case let .view(viewAction): - handleViewAction(state: &state, action: viewAction) - case .async, .inner, .navigation: - .none - } - } -} - -extension HifiCoordinator { - private func routerAction( - state: inout State, - action: IndexedRouterActionOf - ) -> Effect { - switch action { - 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 - } - } -} - -// swiftformat:disable extensionAccessControl -extension HifiCoordinator { - @Reducer - public enum HifiScreen { - case hifi(HifiFeature) - } -} - -// swiftformat:enable extensionAccessControl - -extension HifiCoordinator.HifiScreen.State: Equatable {} diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift deleted file mode 100644 index 2989aebf..00000000 --- a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// HifiCoordinatorView.swift -// Hifi -// - -import Foundation - -import SwiftUI - -import ComposableArchitecture -import TCAFlow - -public struct HifiCoordinatorView: 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 let .hifi(hifiStore): - HifiView(store: hifiStore) - } - } - } -} diff --git a/Projects/Presentation/Hifi/Testing/Sources/HifiTesting.swift b/Projects/Presentation/Hifi/Testing/Sources/HifiTesting.swift deleted file mode 100644 index fff5fb42..00000000 --- a/Projects/Presentation/Hifi/Testing/Sources/HifiTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import HifiInterface - -public enum HifiTesting {} diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift deleted file mode 100644 index 7fe5a9a9..00000000 --- a/Projects/Presentation/Home/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Home), - bundleId: .appBundleID(name: ".Home"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .SPM.tcaFlow, - .SPM.kingfisher, - .Domain(.Attendance, .interface), - .Domain(.Battle, .interface), - .Domain(.Home, .interface), - .Domain(implements: .UseCase), - .Domain(.Notification, .interface), - .Shared(implements: .Shared), - // 홈 피드 중간 배너 광고 — 광고를 노출하는 화면만 명시적으로 의존한다. - .Shared(implements: .AdKit), - ] -) diff --git a/Projects/Presentation/Home/Testing/Sources/HomeTesting.swift b/Projects/Presentation/Home/Testing/Sources/HomeTesting.swift deleted file mode 100644 index 4c116cb6..00000000 --- a/Projects/Presentation/Home/Testing/Sources/HomeTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import HomeInterface - -public enum HomeTesting {} diff --git a/Projects/Presentation/Notification/Project.swift b/Projects/Presentation/Notification/Project.swift deleted file mode 100644 index 99a0fa06..00000000 --- a/Projects/Presentation/Notification/Project.swift +++ /dev/null @@ -1,18 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Notification), - bundleId: .appBundleID(name: ".Notification"), - settings: .settings(), - dependencies: [ - .Domain(.Notification, .interface), - .Domain(implements: .UseCase), - .Shared(implements: .Shared), - .SPM.composableArchitecture, - .SPM.tcaFlow, - ] -) diff --git a/Projects/Presentation/Notification/Testing/Sources/NotificationTesting.swift b/Projects/Presentation/Notification/Testing/Sources/NotificationTesting.swift deleted file mode 100644 index 56067620..00000000 --- a/Projects/Presentation/Notification/Testing/Sources/NotificationTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import NotificationInterface - -public enum NotificationTesting {} diff --git a/Projects/Presentation/Presentation/Project.swift b/Projects/Presentation/Presentation/Project.swift deleted file mode 100644 index 6c2d35ea..00000000 --- a/Projects/Presentation/Presentation/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "Presentation"), - bundleId: .appBundleID(name: ".Presentation"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Presentation(implements: .Splash), - .Presentation(implements: .Auth), - .Presentation(implements: .Web), - .Presentation(implements: .Home), - .Presentation(implements: .Chat), - .Presentation(implements: .Hifi), - .Presentation(implements: .Battle), - .Presentation(implements: .Profile), - .Presentation(implements: .Notification), - ], - sources: ["Sources/**"] -) diff --git a/Projects/Presentation/Presentation/Tests/Sources/Test.swift b/Projects/Presentation/Presentation/Tests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Presentation/Presentation/Tests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift deleted file mode 100644 index 18e8177b..00000000 --- a/Projects/Presentation/Profile/Project.swift +++ /dev/null @@ -1,24 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .feature(.Profile), - bundleId: .appBundleID(name: ".Profile"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Domain(.Profile, .interface), - .Domain(.Auth, .interface), - .Domain(.Battle, .interface), - .Domain(.Notification, .interface), - .Domain(implements: .UseCase), - .Shared(implements: .Shared), - .Shared(implements: .AdKit), // 마이페이지 하단 배너 광고 - .SPM.composableArchitecture, - .SPM.tcaFlow, - .SPM.kingfisher, - ] -) diff --git a/Projects/Presentation/Profile/Testing/Sources/ProfileTesting.swift b/Projects/Presentation/Profile/Testing/Sources/ProfileTesting.swift deleted file mode 100644 index 8f23f859..00000000 --- a/Projects/Presentation/Profile/Testing/Sources/ProfileTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import ProfileInterface - -public enum ProfileTesting {} diff --git a/Projects/Presentation/Splash/Interface/Sources/SplashInterface.swift b/Projects/Presentation/Splash/Interface/Sources/SplashInterface.swift deleted file mode 100644 index 543e144c..00000000 --- a/Projects/Presentation/Splash/Interface/Sources/SplashInterface.swift +++ /dev/null @@ -1 +0,0 @@ -public enum SplashInterface {} diff --git a/Projects/Presentation/Splash/Project.swift b/Projects/Presentation/Splash/Project.swift deleted file mode 100644 index 0d80ecef..00000000 --- a/Projects/Presentation/Splash/Project.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.configure( - moduleType: .feature(.Splash), - bundleId: .appBundleID(name: ".Splash"), - settings: .settings(), - dependencies: [ - .SPM.composableArchitecture, - .SPM.sdwebImageCore, - .Domain(implements: .UseCase), - .Shared(implements: .Shared), - ] -) diff --git a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift deleted file mode 100644 index b399fd6b..00000000 --- a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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/Testing/Sources/SplashTesting.swift b/Projects/Presentation/Splash/Testing/Sources/SplashTesting.swift deleted file mode 100644 index d31fbd9f..00000000 --- a/Projects/Presentation/Splash/Testing/Sources/SplashTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import SplashInterface - -public enum SplashTesting {} diff --git a/Projects/Presentation/Web/Testing/Sources/WebTesting.swift b/Projects/Presentation/Web/Testing/Sources/WebTesting.swift deleted file mode 100644 index b0d61fe6..00000000 --- a/Projects/Presentation/Web/Testing/Sources/WebTesting.swift +++ /dev/null @@ -1,3 +0,0 @@ -import WebInterface - -public enum WebTesting {} diff --git a/Projects/Data/API/Project.swift b/Projects/Service/API/Project.swift similarity index 53% rename from Projects/Data/API/Project.swift rename to Projects/Service/API/Project.swift index 24444282..22c15811 100644 --- a/Projects/Data/API/Project.swift +++ b/Projects/Service/API/Project.swift @@ -1,17 +1,18 @@ +import Foundation + import DependencyPackagePlugin import DependencyPlugin -import Foundation -import ProjectDescription import ProjectTemplatePlugin -let project = Project.configure( - moduleType: .module(name: "API"), +import ProjectDescription + +let project = Project.makeModule( + name: "API", bundleId: .appBundleID(name: ".API"), - product: .staticFramework, + product: .framework, settings: .settings(), dependencies: [ - .Network(implements: .NetworkHeader), + .core(.network, .interface), ], - sources: ["Sources/**"], - hasTests: false -) + hasTests: true +) \ No newline at end of file diff --git a/Projects/Data/Attendance/Sources/API/AttendanceAPI.swift b/Projects/Service/API/Sources/Attendance/AttendanceAPI.swift similarity index 100% rename from Projects/Data/Attendance/Sources/API/AttendanceAPI.swift rename to Projects/Service/API/Sources/Attendance/AttendanceAPI.swift diff --git a/Projects/Data/Auth/Sources/API/AuthApI.swift b/Projects/Service/API/Sources/Auth/AuthAPI.swift similarity index 95% rename from Projects/Data/Auth/Sources/API/AuthApI.swift rename to Projects/Service/API/Sources/Auth/AuthAPI.swift index 1a84a86a..6acbc4c5 100644 --- a/Projects/Data/Auth/Sources/API/AuthApI.swift +++ b/Projects/Service/API/Sources/Auth/AuthAPI.swift @@ -1,5 +1,5 @@ // -// AuthApI.swift +// AuthAPI.swift // API // // Created by Wonji Suh on 5/14/26. diff --git a/Projects/Data/API/Sources/Base/BaseAPI.swift b/Projects/Service/API/Sources/Base/BaseAPI.swift similarity index 100% rename from Projects/Data/API/Sources/Base/BaseAPI.swift rename to Projects/Service/API/Sources/Base/BaseAPI.swift diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Service/API/Sources/Base/PieckeDomain.swift similarity index 97% rename from Projects/Data/API/Sources/Base/PieckeDomain.swift rename to Projects/Service/API/Sources/Base/PieckeDomain.swift index d5a85127..42e94c6e 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Service/API/Sources/Base/PieckeDomain.swift @@ -7,7 +7,7 @@ import Foundation -import NetworkHeader +import PickeNetworkInterface public enum PieckeDomain { case attendance diff --git a/Projects/Data/Battle/Sources/API/BattleAPI.swift b/Projects/Service/API/Sources/Battle/BattleAPI.swift similarity index 100% rename from Projects/Data/Battle/Sources/API/BattleAPI.swift rename to Projects/Service/API/Sources/Battle/BattleAPI.swift diff --git a/Projects/Data/Comment/Sources/API/CommentAPI.swift b/Projects/Service/API/Sources/Comment/CommentAPI.swift similarity index 100% rename from Projects/Data/Comment/Sources/API/CommentAPI.swift rename to Projects/Service/API/Sources/Comment/CommentAPI.swift diff --git a/Projects/Data/Home/Sources/API/HomeAPI.swift b/Projects/Service/API/Sources/Home/HomeAPI.swift similarity index 100% rename from Projects/Data/Home/Sources/API/HomeAPI.swift rename to Projects/Service/API/Sources/Home/HomeAPI.swift diff --git a/Projects/Data/Notification/Sources/API/NotificationAPI.swift b/Projects/Service/API/Sources/Notification/NotificationAPI.swift similarity index 100% rename from Projects/Data/Notification/Sources/API/NotificationAPI.swift rename to Projects/Service/API/Sources/Notification/NotificationAPI.swift diff --git a/Projects/Data/Perspective/Sources/API/PerspectiveAPI.swift b/Projects/Service/API/Sources/Perspective/PerspectiveAPI.swift similarity index 100% rename from Projects/Data/Perspective/Sources/API/PerspectiveAPI.swift rename to Projects/Service/API/Sources/Perspective/PerspectiveAPI.swift diff --git a/Projects/Data/Profile/Sources/API/ProfileAPI.swift b/Projects/Service/API/Sources/Profile/ProfileAPI.swift similarity index 100% rename from Projects/Data/Profile/Sources/API/ProfileAPI.swift rename to Projects/Service/API/Sources/Profile/ProfileAPI.swift diff --git a/Projects/Data/Search/Sources/SearchAPI.swift b/Projects/Service/API/Sources/Search/SearchAPI.swift similarity index 100% rename from Projects/Data/Search/Sources/SearchAPI.swift rename to Projects/Service/API/Sources/Search/SearchAPI.swift diff --git a/Projects/Service/API/Tests/Sources/PieckeDomainTests.swift b/Projects/Service/API/Tests/Sources/PieckeDomainTests.swift new file mode 100644 index 00000000..1b7a3022 --- /dev/null +++ b/Projects/Service/API/Tests/Sources/PieckeDomainTests.swift @@ -0,0 +1,36 @@ +// +// PieckeDomainTests.swift +// APITests +// + +import Testing + +@testable import API +import PickeNetworkInterface + +struct PieckeDomainTests { + @Test + func 모든_도메인_경로는_api_v1_아래에_있다() { + for domain in [ + PieckeDomain.attendance, .auth, .profile, .home, .poll, + .battle, .comment, .perspective, .search, .notification, .device, + ] { + #expect(domain.url.hasPrefix("api/v1/")) + } + } + + @Test + func 도메인마다_서로_다른_경로를_쓴다() { + let urls = [ + PieckeDomain.attendance, .auth, .profile, .home, .poll, + .battle, .comment, .perspective, .search, .notification, .device, + ].map(\.url) + + #expect(Set(urls).count == urls.count) + } + + @Test + func baseURL_은_https_스킴을_붙여_만든다() { + #expect(PieckeDomain.auth.baseURLString.hasPrefix("https://")) + } +} diff --git a/Projects/Service/APIEndpoint/Project.swift b/Projects/Service/APIEndpoint/Project.swift new file mode 100644 index 00000000..0ca278d1 --- /dev/null +++ b/Projects/Service/APIEndpoint/Project.swift @@ -0,0 +1,23 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "APIEndpoint", + bundleId: .appBundleID(name: ".APIEndpoint"), + product: .framework, + settings: .settings(), + dependencies: [ + // 엔드포인트가 HTTPMethod·HTTPHeaders 를 직접 쓴다. PickeNetwork 가 더는 재노출하지 않는다. + .SPM.alamofire, + .service(.api), + // Auth 엔드포인트가 SocialType 을 경로에 쓴다. + .domain(.auth, .interface), + .core(.network, .interface), + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Data/Attendance/Sources/Service/AttendanceService.swift b/Projects/Service/APIEndpoint/Sources/Attendance/AttendanceService.swift similarity index 60% rename from Projects/Data/Attendance/Sources/Service/AttendanceService.swift rename to Projects/Service/APIEndpoint/Sources/Attendance/AttendanceService.swift index 04ea279f..8b89dbc8 100644 --- a/Projects/Data/Attendance/Sources/Service/AttendanceService.swift +++ b/Projects/Service/APIEndpoint/Sources/Attendance/AttendanceService.swift @@ -5,9 +5,9 @@ import Foundation +import Alamofire import API -import NetworkHeader -import Service +import PickeNetwork public enum AttendanceService { case check @@ -15,12 +15,10 @@ public enum AttendanceService { case summary } -extension AttendanceService: PickeTargetType { - public typealias Domain = PieckeDomain +extension AttendanceService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.attendance } - public var domain: PieckeDomain { .attendance } - - public var urlPath: String { + public var path: String { switch self { case .check: return AttendanceAPI.check.description @@ -39,12 +37,4 @@ extension AttendanceService: PickeTargetType { return .get } } - - public var parameters: [String: Any]? { - return nil - } - - public var headers: [String: String]? { - return APIHeader.baseHeader - } } diff --git a/Projects/Data/Auth/Sources/Service/AuthService.swift b/Projects/Service/APIEndpoint/Sources/Auth/AuthService.swift similarity index 52% rename from Projects/Data/Auth/Sources/Service/AuthService.swift rename to Projects/Service/APIEndpoint/Sources/Auth/AuthService.swift index 62a673f9..184d714d 100644 --- a/Projects/Data/Auth/Sources/Service/AuthService.swift +++ b/Projects/Service/APIEndpoint/Sources/Auth/AuthService.swift @@ -7,10 +7,10 @@ import Foundation +import Alamofire import API import AuthDomainInterface -import NetworkHeader -import Service +import PickeNetwork public enum AuthService { case login(provider: SocialType, body: OAuthLoginRequest) @@ -19,19 +19,17 @@ public enum AuthService { case logout } -extension AuthService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { +extension AuthService: PickeDataRequest { + public var domain: any PickeDomainType { switch self { case .login, .refresh, .logout: - return .auth + return PieckeDomain.auth case .withdraw: - return .profile + return PieckeDomain.profile } } - public var urlPath: String { + public var path: String { switch self { case let .login(provider, _): return "\(AuthAPI.login.description)/\(provider.rawValue)" @@ -53,29 +51,43 @@ extension AuthService: PickeTargetType { } } - public var parameters: [String: Any]? { + public var parameters: (any Encodable & Sendable)? { switch self { case let .login(_, body): - return body.toDictionary - case .refresh: - return nil + return body case let .withdraw(reason): - return reason.toDictionary(key: "reason") - case .logout: + return WithdrawRequest(reason: reason) + case .refresh, .logout: return nil } } - public var headers: [String: String]? { + /// withdraw 는 DELETE 지만 reason 을 JSON 바디로 보낸다(서버 규약). + public var parameterEncoder: ParameterEncoder? { + switch self { + case .withdraw: + return JSONParameterEncoder.default + default: + return nil + } + } + + public var headers: HTTPHeaders { switch self { case let .refresh(refreshToken): - var headers = APIHeader.notAccessTokenHeader - headers[APIHeader.refreshToken] = refreshToken - return headers - case .withdraw, .logout: - return APIHeader.baseHeader + return [APIHeader.refreshToken: refreshToken] default: - return APIHeader.notAccessTokenHeader + return [:] + } + } + + /// 로그인·토큰 재발급은 아직 액세스 토큰이 없거나 자기 자신이 갱신 경로다 — 인증 파이프라인을 우회한다. + public var authorization: PickeAuthorization { + switch self { + case .login, .refresh: + return .none + case .withdraw, .logout: + return .automatic } } } diff --git a/Projects/Data/Auth/Sources/Service/Request/OAuthRequest.swift b/Projects/Service/APIEndpoint/Sources/Auth/Request/OAuthRequest.swift similarity index 93% rename from Projects/Data/Auth/Sources/Service/Request/OAuthRequest.swift rename to Projects/Service/APIEndpoint/Sources/Auth/Request/OAuthRequest.swift index 7176e7c8..028719d2 100644 --- a/Projects/Data/Auth/Sources/Service/Request/OAuthRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Auth/Request/OAuthRequest.swift @@ -10,7 +10,7 @@ import Foundation /// `/api/v1/auth/login/{provider}` 요청 바디. /// - `idToken`: Apple 로그인에서만 채워서 보낸다 (JSON key: `identityToken`). /// - `redirectUri`: Apple 은 nil 로 보낸다 (서버에서 redirect 사용 X). -public struct OAuthLoginRequest: Encodable { +public struct OAuthLoginRequest: Encodable, Sendable { public let authorizationCode: String public let redirectUri: String? public let idToken: String? diff --git a/Projects/Service/APIEndpoint/Sources/Auth/Request/WithdrawRequest.swift b/Projects/Service/APIEndpoint/Sources/Auth/Request/WithdrawRequest.swift new file mode 100644 index 00000000..4057779a --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Auth/Request/WithdrawRequest.swift @@ -0,0 +1,14 @@ +// +// WithdrawRequest.swift +// APIEndpoint +// + +import Foundation + +public struct WithdrawRequest: Encodable, Sendable { + public let reason: String + + public init(reason: String) { + self.reason = reason + } +} diff --git a/Projects/Data/Battle/Sources/Service/BattleService.swift b/Projects/Service/APIEndpoint/Sources/Battle/BattleService.swift similarity index 74% rename from Projects/Data/Battle/Sources/Service/BattleService.swift rename to Projects/Service/APIEndpoint/Sources/Battle/BattleService.swift index 07bbb231..c7b7fb7e 100644 --- a/Projects/Data/Battle/Sources/Service/BattleService.swift +++ b/Projects/Service/APIEndpoint/Sources/Battle/BattleService.swift @@ -5,9 +5,9 @@ import Foundation +import Alamofire import API -import NetworkHeader - +import PickeNetwork public enum BattleService { case today @@ -23,12 +23,10 @@ public enum BattleService { case createProposal(body: BattleProposalRequest) } -extension BattleService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .battle } +extension BattleService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.battle } - public var urlPath: String { + public var path: String { switch self { case .today: return BattleAPI.today.description @@ -55,7 +53,6 @@ extension BattleService: PickeTargetType { } } - public var method: HTTPMethod { switch self { case .today, .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: @@ -65,35 +62,20 @@ extension BattleService: PickeTargetType { } } - public var parameters: [String: Any]? { + public var parameters: (any Encodable & Sendable)? { switch self { - case .today: - return nil - case .detail: - return nil case let .preVote(_, body): - return body.toDictionary + return body case let .postVote(_, body): - return body.toDictionary - case .scenario: - return nil - case .voteStats: - return nil + return body case let .perspectives(_, query): - guard let dict = query.toDictionary else { return nil } - return dict.isEmpty ? nil : dict + return query case let .createPerspective(_, body): - return body.toDictionary + return body case let .createProposal(body): - return body.toDictionary - case .myPerspective: - return nil - case .recommendations: + return body + case .today, .detail, .scenario, .voteStats, .myPerspective, .recommendations: return nil } } - - public var headers: [String: String]? { - return APIHeader.baseHeader - } } diff --git a/Projects/Data/Battle/Sources/Service/Request/BattleProposalRequest.swift b/Projects/Service/APIEndpoint/Sources/Battle/Request/BattleProposalRequest.swift similarity index 94% rename from Projects/Data/Battle/Sources/Service/Request/BattleProposalRequest.swift rename to Projects/Service/APIEndpoint/Sources/Battle/Request/BattleProposalRequest.swift index 480e2047..be8498c0 100644 --- a/Projects/Data/Battle/Sources/Service/Request/BattleProposalRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Battle/Request/BattleProposalRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct BattleProposalRequest: Encodable { +public struct BattleProposalRequest: Encodable, Sendable { public let category: String public let topic: String public let positionA: String diff --git a/Projects/Data/Battle/Sources/Service/Request/CreatePerspectiveRequest.swift b/Projects/Service/APIEndpoint/Sources/Battle/Request/CreatePerspectiveRequest.swift similarity index 90% rename from Projects/Data/Battle/Sources/Service/Request/CreatePerspectiveRequest.swift rename to Projects/Service/APIEndpoint/Sources/Battle/Request/CreatePerspectiveRequest.swift index f9e94eec..6f1493d9 100644 --- a/Projects/Data/Battle/Sources/Service/Request/CreatePerspectiveRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Battle/Request/CreatePerspectiveRequest.swift @@ -7,7 +7,7 @@ import Foundation -public struct CreatePerspectiveRequest: Encodable { +public struct CreatePerspectiveRequest: Encodable, Sendable { public let content: String public let optionId: Int? diff --git a/Projects/Data/Battle/Sources/Service/Request/PerspectivesQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Battle/Request/PerspectivesQueryRequest.swift similarity index 86% rename from Projects/Data/Battle/Sources/Service/Request/PerspectivesQueryRequest.swift rename to Projects/Service/APIEndpoint/Sources/Battle/Request/PerspectivesQueryRequest.swift index efecb725..ae637e9b 100644 --- a/Projects/Data/Battle/Sources/Service/Request/PerspectivesQueryRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Battle/Request/PerspectivesQueryRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct PerspectivesQueryRequest: Encodable { +public struct PerspectivesQueryRequest: Encodable, Sendable { public let cursor: String? public let size: Int? public let optionId: Int? diff --git a/Projects/Data/Battle/Sources/Service/Request/PreVoteRequest.swift b/Projects/Service/APIEndpoint/Sources/Battle/Request/PreVoteRequest.swift similarity index 79% rename from Projects/Data/Battle/Sources/Service/Request/PreVoteRequest.swift rename to Projects/Service/APIEndpoint/Sources/Battle/Request/PreVoteRequest.swift index dda35921..67f93d24 100644 --- a/Projects/Data/Battle/Sources/Service/Request/PreVoteRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Battle/Request/PreVoteRequest.swift @@ -7,7 +7,7 @@ import Foundation -public struct PreVoteRequest: Encodable { +public struct PreVoteRequest: Encodable, Sendable { public let optionId: Int public init(optionId: Int) { diff --git a/Projects/Data/Comment/Sources/Service/CommentService.swift b/Projects/Service/APIEndpoint/Sources/Comment/CommentService.swift similarity index 61% rename from Projects/Data/Comment/Sources/Service/CommentService.swift rename to Projects/Service/APIEndpoint/Sources/Comment/CommentService.swift index 40ae46b9..b12b350e 100644 --- a/Projects/Data/Comment/Sources/Service/CommentService.swift +++ b/Projects/Service/APIEndpoint/Sources/Comment/CommentService.swift @@ -5,21 +5,19 @@ import Foundation +import Alamofire import API -import NetworkHeader - +import PickeNetwork public enum CommentService { case like(commentId: Int) case unlike(commentId: Int) } -extension CommentService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .comment } +extension CommentService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.comment } - public var urlPath: String { + public var path: String { switch self { case let .like(commentId): CommentAPI.like(commentId: commentId).description @@ -28,7 +26,6 @@ extension CommentService: PickeTargetType { } } - public var method: HTTPMethod { switch self { case .like: @@ -37,10 +34,4 @@ extension CommentService: PickeTargetType { .delete } } - - public var parameters: [String: Any]? { nil } - - public var headers: [String: String]? { - APIHeader.baseHeader - } } diff --git a/Projects/Data/Service/Sources/Common/Encodable+.swift b/Projects/Service/APIEndpoint/Sources/Common/Encodable+.swift similarity index 100% rename from Projects/Data/Service/Sources/Common/Encodable+.swift rename to Projects/Service/APIEndpoint/Sources/Common/Encodable+.swift diff --git a/Projects/Service/APIEndpoint/Sources/Device/DeviceService.swift b/Projects/Service/APIEndpoint/Sources/Device/DeviceService.swift new file mode 100644 index 00000000..56125030 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Device/DeviceService.swift @@ -0,0 +1,41 @@ +// +// DeviceService.swift +// Service +// + +import Foundation + +import Alamofire +import API +import PickeNetwork + +public enum DeviceService { + /// POST /api/v1/devices + case register(body: DeviceRegisterRequest) + /// DELETE /api/v1/devices?fcmToken=... + case unregister(fcmToken: String) +} + +extension DeviceService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.device } + + public var path: String { "" } + + public var method: HTTPMethod { + switch self { + case .register: + return .post + case .unregister: + return .delete + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .register(body): + return body + case let .unregister(fcmToken): + return DeviceUnregisterQueryRequest(fcmToken: fcmToken) + } + } +} diff --git a/Projects/Data/Service/Sources/Device/Request/DeviceRegisterRequest.swift b/Projects/Service/APIEndpoint/Sources/Device/Request/DeviceRegisterRequest.swift similarity index 80% rename from Projects/Data/Service/Sources/Device/Request/DeviceRegisterRequest.swift rename to Projects/Service/APIEndpoint/Sources/Device/Request/DeviceRegisterRequest.swift index 6000dd28..a31bb5d8 100644 --- a/Projects/Data/Service/Sources/Device/Request/DeviceRegisterRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Device/Request/DeviceRegisterRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct DeviceRegisterRequest: Encodable { +public struct DeviceRegisterRequest: Encodable, Sendable { public let fcmToken: String public let platform: String diff --git a/Projects/Service/APIEndpoint/Sources/Device/Request/DeviceUnregisterQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Device/Request/DeviceUnregisterQueryRequest.swift new file mode 100644 index 00000000..976f4bec --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Device/Request/DeviceUnregisterQueryRequest.swift @@ -0,0 +1,14 @@ +// +// DeviceUnregisterQueryRequest.swift +// APIEndpoint +// + +import Foundation + +public struct DeviceUnregisterQueryRequest: Encodable, Sendable { + public let fcmToken: String + + public init(fcmToken: String) { + self.fcmToken = fcmToken + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Home/HomeService.swift b/Projects/Service/APIEndpoint/Sources/Home/HomeService.swift new file mode 100644 index 00000000..cc2c9e3c --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Home/HomeService.swift @@ -0,0 +1,34 @@ +// +// HomeService.swift +// Service +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +import Alamofire +import API +import PickeNetwork + +public enum HomeService { + case home +} + +extension HomeService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.home } + + public var path: String { + switch self { + case .home: + return HomeAPI.home.description + } + } + + public var method: HTTPMethod { + switch self { + case .home: + return .get + } + } +} diff --git a/Projects/Data/Notification/Sources/Service/NotificationService.swift b/Projects/Service/APIEndpoint/Sources/Notification/NotificationService.swift similarity index 69% rename from Projects/Data/Notification/Sources/Service/NotificationService.swift rename to Projects/Service/APIEndpoint/Sources/Notification/NotificationService.swift index c99f4419..08caeba5 100644 --- a/Projects/Data/Notification/Sources/Service/NotificationService.swift +++ b/Projects/Service/APIEndpoint/Sources/Notification/NotificationService.swift @@ -5,10 +5,9 @@ import Foundation +import Alamofire import API -import NetworkHeader -import Service - +import PickeNetwork public enum NotificationService { case list(query: NotificationsQueryRequest) @@ -18,12 +17,10 @@ public enum NotificationService { case readAll } -extension NotificationService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .notification } +extension NotificationService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.notification } - public var urlPath: String { + public var path: String { switch self { case .list: return NotificationAPI.list.description @@ -38,7 +35,6 @@ extension NotificationService: PickeTargetType { } } - public var method: HTTPMethod { switch self { case .list, .unread, .detail: @@ -48,17 +44,12 @@ extension NotificationService: PickeTargetType { } } - public var parameters: [String: Any]? { + public var parameters: (any Encodable & Sendable)? { switch self { case let .list(query): - guard let dict = query.toDictionary else { return nil } - return dict.isEmpty ? nil : dict + return query case .unread, .detail, .read, .readAll: return nil } } - - public var headers: [String: String]? { - return APIHeader.baseHeader - } } diff --git a/Projects/Data/Notification/Sources/Service/Request/NotificationsQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Notification/Request/NotificationsQueryRequest.swift similarity index 85% rename from Projects/Data/Notification/Sources/Service/Request/NotificationsQueryRequest.swift rename to Projects/Service/APIEndpoint/Sources/Notification/Request/NotificationsQueryRequest.swift index c54107c1..84159820 100644 --- a/Projects/Data/Notification/Sources/Service/Request/NotificationsQueryRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Notification/Request/NotificationsQueryRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct NotificationsQueryRequest: Encodable { +public struct NotificationsQueryRequest: Encodable, Sendable { /// ALL / CONTENT / NOTICE / EVENT. public let category: String? public let page: Int? diff --git a/Projects/Data/Perspective/Sources/Service/PerspectiveService.swift b/Projects/Service/APIEndpoint/Sources/Perspective/PerspectiveService.swift similarity index 78% rename from Projects/Data/Perspective/Sources/Service/PerspectiveService.swift rename to Projects/Service/APIEndpoint/Sources/Perspective/PerspectiveService.swift index a2b5005f..3d056211 100644 --- a/Projects/Data/Perspective/Sources/Service/PerspectiveService.swift +++ b/Projects/Service/APIEndpoint/Sources/Perspective/PerspectiveService.swift @@ -5,11 +5,11 @@ import Foundation +import Alamofire import API -import NetworkHeader +import PickeNetwork - -public struct PerspectiveCommentBody: Encodable { +public struct PerspectiveCommentBody: Encodable, Sendable { public let content: String public init(content: String) { self.content = content } } @@ -29,12 +29,10 @@ public enum PerspectiveService { case reportComment(perspectiveId: Int, commentId: Int) } -extension PerspectiveService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .perspective } +extension PerspectiveService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.perspective } - public var urlPath: String { + public var path: String { switch self { case let .detail(perspectiveId): return PerspectiveAPI.detail(perspectiveId: perspectiveId).description @@ -63,7 +61,6 @@ extension PerspectiveService: PickeTargetType { } } - public var method: HTTPMethod { switch self { case .detail, .listLabeledComments, .fetchPerspectiveLikes: @@ -77,33 +74,25 @@ extension PerspectiveService: PickeTargetType { } } - public var parameters: [String: Any]? { + public var parameters: (any Encodable & Sendable)? { switch self { - case .detail: - return nil case let .listLabeledComments(_, cursor, size): - var query: [String: Any] = [:] - if let cursor { query["cursor"] = cursor } - if let size { query["size"] = size } - return query.isEmpty ? nil : query + return PerspectiveCommentsQueryRequest(cursor: cursor, size: size) case let .createComment(_, body): - return body.toDictionary + return body case let .updateComment(_, _, body): - return body.toDictionary + return body case let .updatePerspective(_, body): - return body.toDictionary - case .deleteComment: - return nil - case .deletePerspective: - return nil - case .likePerspective, .unlikePerspective, .fetchPerspectiveLikes: - return nil - case .reportPerspective, .reportComment: + return body + case .detail, + .deleteComment, + .deletePerspective, + .likePerspective, + .unlikePerspective, + .fetchPerspectiveLikes, + .reportPerspective, + .reportComment: return nil } } - - public var headers: [String: String]? { - APIHeader.baseHeader - } } diff --git a/Projects/Service/APIEndpoint/Sources/Perspective/Request/PerspectiveCommentsQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Perspective/Request/PerspectiveCommentsQueryRequest.swift new file mode 100644 index 00000000..e093d10e --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Perspective/Request/PerspectiveCommentsQueryRequest.swift @@ -0,0 +1,16 @@ +// +// PerspectiveCommentsQueryRequest.swift +// APIEndpoint +// + +import Foundation + +public struct PerspectiveCommentsQueryRequest: Encodable, Sendable { + public let cursor: String? + public let size: Int? + + public init(cursor: String?, size: Int?) { + self.cursor = cursor + self.size = size + } +} diff --git a/Projects/Data/Profile/Sources/Service/ProfileService.swift b/Projects/Service/APIEndpoint/Sources/Profile/ProfileService.swift similarity index 63% rename from Projects/Data/Profile/Sources/Service/ProfileService.swift rename to Projects/Service/APIEndpoint/Sources/Profile/ProfileService.swift index 4c1fdd0e..fcfe345c 100644 --- a/Projects/Data/Profile/Sources/Service/ProfileService.swift +++ b/Projects/Service/APIEndpoint/Sources/Profile/ProfileService.swift @@ -5,10 +5,9 @@ import Foundation +import Alamofire import API -import NetworkHeader -import Service - +import PickeNetwork public enum ProfileService { case mypage @@ -21,12 +20,10 @@ public enum ProfileService { case updateProfile(body: ProfileUpdateRequest) } -extension ProfileService: PickeTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .profile } +extension ProfileService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.profile } - public var urlPath: String { + public var path: String { switch self { case .mypage: return ProfileAPI.mypage.description @@ -47,7 +44,6 @@ extension ProfileService: PickeTargetType { } } - public var method: HTTPMethod { switch self { case .mypage, .recap, .creditsHistory, .battleRecords, .contentActivities, .notificationSettings: @@ -57,33 +53,20 @@ extension ProfileService: PickeTargetType { } } - public var parameters: [String: Any]? { + public var parameters: (any Encodable & Sendable)? { switch self { - case .mypage: - return nil - case .recap: - return nil case let .creditsHistory(query): - guard let dict = query.toDictionary else { return nil } - return dict.isEmpty ? nil : dict + return query case let .battleRecords(query): - guard let dict = query.toDictionary else { return nil } - return dict.isEmpty ? nil : dict + return query case let .contentActivities(query): - guard let dict = query.toDictionary else { return nil } - return dict.isEmpty ? nil : dict - case .notificationSettings: - return nil + return query case let .updateNotificationSettings(body): - guard let dict = body.toDictionary else { return nil } - return dict.isEmpty ? nil : dict + return body case let .updateProfile(body): - guard let dict = body.toDictionary else { return nil } - return dict.isEmpty ? nil : dict + return body + case .mypage, .recap, .notificationSettings: + return nil } } - - public var headers: [String: String]? { - return APIHeader.baseHeader - } } diff --git a/Projects/Data/Profile/Sources/Service/Request/BattleRecordsQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Profile/Request/BattleRecordsQueryRequest.swift similarity index 88% rename from Projects/Data/Profile/Sources/Service/Request/BattleRecordsQueryRequest.swift rename to Projects/Service/APIEndpoint/Sources/Profile/Request/BattleRecordsQueryRequest.swift index bf0337da..4c99bd6c 100644 --- a/Projects/Data/Profile/Sources/Service/Request/BattleRecordsQueryRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Profile/Request/BattleRecordsQueryRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct BattleRecordsQueryRequest: Encodable { +public struct BattleRecordsQueryRequest: Encodable, Sendable { public let offset: Int? public let size: Int? /// 투표 진영 필터 (PRO / CON). nil 이면 전체. diff --git a/Projects/Data/Profile/Sources/Service/Request/ContentActivitiesQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Profile/Request/ContentActivitiesQueryRequest.swift similarity index 88% rename from Projects/Data/Profile/Sources/Service/Request/ContentActivitiesQueryRequest.swift rename to Projects/Service/APIEndpoint/Sources/Profile/Request/ContentActivitiesQueryRequest.swift index 6c0fcb96..b8e85d0e 100644 --- a/Projects/Data/Profile/Sources/Service/Request/ContentActivitiesQueryRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Profile/Request/ContentActivitiesQueryRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct ContentActivitiesQueryRequest: Encodable { +public struct ContentActivitiesQueryRequest: Encodable, Sendable { public let offset: Int? public let size: Int? /// 활동 유형 필터 (COMMENT / LIKE). nil 이면 전체. diff --git a/Projects/Data/Profile/Sources/Service/Request/CreditHistoryQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Profile/Request/CreditHistoryQueryRequest.swift similarity index 79% rename from Projects/Data/Profile/Sources/Service/Request/CreditHistoryQueryRequest.swift rename to Projects/Service/APIEndpoint/Sources/Profile/Request/CreditHistoryQueryRequest.swift index 9929bce4..72c48af6 100644 --- a/Projects/Data/Profile/Sources/Service/Request/CreditHistoryQueryRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Profile/Request/CreditHistoryQueryRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct CreditHistoryQueryRequest: Encodable { +public struct CreditHistoryQueryRequest: Encodable, Sendable { public let offset: Int? public let size: Int? diff --git a/Projects/Data/Profile/Sources/Service/Request/NotificationSettingsRequest.swift b/Projects/Service/APIEndpoint/Sources/Profile/Request/NotificationSettingsRequest.swift similarity index 92% rename from Projects/Data/Profile/Sources/Service/Request/NotificationSettingsRequest.swift rename to Projects/Service/APIEndpoint/Sources/Profile/Request/NotificationSettingsRequest.swift index 481c8b3f..e3b04224 100644 --- a/Projects/Data/Profile/Sources/Service/Request/NotificationSettingsRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Profile/Request/NotificationSettingsRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct NotificationSettingsRequest: Encodable { +public struct NotificationSettingsRequest: Encodable, Sendable { public let newBattleEnabled: Bool public let battleResultEnabled: Bool public let commentReplyEnabled: Bool diff --git a/Projects/Data/Profile/Sources/Service/Request/ProfileUpdateRequest.swift b/Projects/Service/APIEndpoint/Sources/Profile/Request/ProfileUpdateRequest.swift similarity index 82% rename from Projects/Data/Profile/Sources/Service/Request/ProfileUpdateRequest.swift rename to Projects/Service/APIEndpoint/Sources/Profile/Request/ProfileUpdateRequest.swift index 49995f17..5563273d 100644 --- a/Projects/Data/Profile/Sources/Service/Request/ProfileUpdateRequest.swift +++ b/Projects/Service/APIEndpoint/Sources/Profile/Request/ProfileUpdateRequest.swift @@ -5,7 +5,7 @@ import Foundation -public struct ProfileUpdateRequest: Encodable { +public struct ProfileUpdateRequest: Encodable, Sendable { public let nickname: String public let characterType: String diff --git a/Projects/Service/APIEndpoint/Sources/Search/Request/SearchBattlesQueryRequest.swift b/Projects/Service/APIEndpoint/Sources/Search/Request/SearchBattlesQueryRequest.swift new file mode 100644 index 00000000..5a4b377f --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Search/Request/SearchBattlesQueryRequest.swift @@ -0,0 +1,26 @@ +// +// SearchBattlesQueryRequest.swift +// APIEndpoint +// + +import Foundation + +public struct SearchBattlesQueryRequest: Encodable, Sendable { + public let category: String? + public let sort: String? + public let offset: Int? + public let size: Int? + + public init( + category: String?, + sort: String?, + offset: Int?, + size: Int? + ) { + // 빈 문자열은 쿼리에서 제외한다(기존 동작 보존). + self.category = (category?.isEmpty == false) ? category : nil + self.sort = (sort?.isEmpty == false) ? sort : nil + self.offset = offset + self.size = size + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Search/SearchService.swift b/Projects/Service/APIEndpoint/Sources/Search/SearchService.swift new file mode 100644 index 00000000..555e5a1a --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Search/SearchService.swift @@ -0,0 +1,44 @@ +// +// SearchService.swift +// Service +// + +import Foundation + +import Alamofire +import API +import PickeNetwork + +public enum SearchService { + case battles(category: String?, sort: String?, offset: Int?, size: Int?) +} + +extension SearchService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.search } + + public var path: String { + switch self { + case .battles: + return SearchAPI.battles.description + } + } + + public var method: HTTPMethod { + switch self { + case .battles: + return .get + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .battles(category, sort, offset, size): + return SearchBattlesQueryRequest( + category: category, + sort: sort, + offset: offset, + size: size + ) + } + } +} diff --git a/Projects/Service/APIEndpoint/Tests/Sources/AuthServiceTests.swift b/Projects/Service/APIEndpoint/Tests/Sources/AuthServiceTests.swift new file mode 100644 index 00000000..14b01165 --- /dev/null +++ b/Projects/Service/APIEndpoint/Tests/Sources/AuthServiceTests.swift @@ -0,0 +1,55 @@ +// +// AuthServiceTests.swift +// APIEndpointTests +// + +import Testing + +@testable import APIEndpoint + +import Alamofire +import API +import AuthDomainInterface +import PickeNetwork +import PickeNetworkInterface + +struct AuthServiceTests { + @Test + func 로그인_경로에_소셜_제공자가_붙는다() { + let sut = AuthService.login(provider: .kakao, body: .init(authorizationCode: "code", redirectUri: nil)) + + #expect(sut.path == "login/kakao") + #expect(sut.method == .post) + } + + @Test + func 탈퇴는_profile_도메인을_쓴다() { + #expect(AuthService.withdraw(reason: "이유").domain.url == PieckeDomain.profile.url) + #expect(AuthService.logout.domain.url == PieckeDomain.auth.url) + } + + /// DELETE 인데도 바디를 보내야 해서 인코더를 따로 지정한다. 빠지면 reason 이 서버에 도달하지 않는다. + @Test + func 탈퇴만_JSON_바디_인코더를_지정한다() { + #expect(AuthService.withdraw(reason: "이유").method == .delete) + #expect(AuthService.withdraw(reason: "이유").parameterEncoder != nil) + #expect(AuthService.logout.parameterEncoder == nil) + } + + @Test + func 재발급은_refresh_token_을_헤더로_보낸다() { + let sut = AuthService.refresh(refreshToken: "refresh-token") + + #expect(sut.headers[APIHeader.refreshToken] == "refresh-token") + #expect(sut.parameters == nil) + } + + /// 로그인·재발급에 액세스 토큰을 붙이면 만료 상태에서 재발급 자체가 막힌다. + @Test + func 로그인과_재발급은_인증_파이프라인을_우회한다() { + #expect(AuthService.login(provider: .apple, body: .init(authorizationCode: "code", redirectUri: nil)).authorization == .none) + #expect(AuthService.refresh(refreshToken: "t").authorization == .none) + #expect(AuthService.logout.authorization == .automatic) + #expect(AuthService.withdraw(reason: "이유").authorization == .automatic) + } +} diff --git a/Projects/Service/APIEndpoint/Tests/Sources/EncodableDictionaryTests.swift b/Projects/Service/APIEndpoint/Tests/Sources/EncodableDictionaryTests.swift new file mode 100644 index 00000000..602acfb4 --- /dev/null +++ b/Projects/Service/APIEndpoint/Tests/Sources/EncodableDictionaryTests.swift @@ -0,0 +1,38 @@ +// +// EncodableDictionaryTests.swift +// APIEndpointTests +// + +import Foundation +import Testing + +@testable import APIEndpoint + +struct EncodableDictionaryTests { + private struct Payload: Encodable { + let reason: String + let count: Int + } + + @Test + func Encodable_을_파라미터_딕셔너리로_바꾼다() throws { + let dictionary = try #require(Payload(reason: "이유", count: 2).toDictionary) + + #expect(dictionary["reason"] as? String == "이유") + #expect(dictionary["count"] as? Int == 2) + } + + /// URL 이 `https:\/\/` 로 이스케이프되면 서버 로그와 대조가 어려워 옵션을 꺼 뒀다. + @Test + func 슬래시를_이스케이프하지_않는다() throws { + let dictionary = try #require(Payload(reason: "https://picke.io/a", count: 0).toDictionary) + + #expect(dictionary["reason"] as? String == "https://picke.io/a") + } + + @Test + func 스칼라를_지정한_키의_딕셔너리로_감싼다() { + #expect("값".toDictionary(key: "reason")["reason"] as? String == "값") + #expect(7.toDictionary(key: "count")["count"] as? Int == 7) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/AudioPlayer/Interface/AudioPlayerInterface.swift b/Projects/Service/AudioPlayerService/Interface/AudioPlayerInterface.swift similarity index 70% rename from Projects/Domain/DomainInterface/Sources/AudioPlayer/Interface/AudioPlayerInterface.swift rename to Projects/Service/AudioPlayerService/Interface/AudioPlayerInterface.swift index db2bf446..3cd96b6e 100644 --- a/Projects/Domain/DomainInterface/Sources/AudioPlayer/Interface/AudioPlayerInterface.swift +++ b/Projects/Service/AudioPlayerService/Interface/AudioPlayerInterface.swift @@ -1,11 +1,10 @@ // // AudioPlayerInterface.swift -// DomainInterface +// AudioPlayerServiceInterface // import Dependencies import Foundation -import WeaveDI public protocol AudioPlayerInterface: Sendable { /// 음원을 로드하고 재생 가능 여부를 반환한다. (false = 오디오 로딩 실패) @@ -28,16 +27,9 @@ public struct DefaultAudioPlayerImpl: AudioPlayerInterface { public func currentTimes() -> AsyncStream { AsyncStream { _ in } } } -public struct AudioPlayerDependency: DependencyKey { - public static var liveValue: AudioPlayerInterface { - UnifiedDI.resolve(AudioPlayerInterface.self) ?? DefaultAudioPlayerImpl() - } - - public static var testValue: AudioPlayerInterface { - UnifiedDI.resolve(AudioPlayerInterface.self) ?? DefaultAudioPlayerImpl() - } - - public static var previewValue: AudioPlayerInterface = liveValue +/// live 구현(`AudioPlayerRepositoryImpl`)은 AudioPlayerService 가 `DependencyKey` 로 이어 붙인다. +public struct AudioPlayerDependency: TestDependencyKey { + public static var testValue: AudioPlayerInterface { DefaultAudioPlayerImpl() } } public extension DependencyValues { diff --git a/Projects/Service/AudioPlayerService/Project.swift b/Projects/Service/AudioPlayerService/Project.swift new file mode 100644 index 00000000..f447d898 --- /dev/null +++ b/Projects/Service/AudioPlayerService/Project.swift @@ -0,0 +1,23 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "AudioPlayerService", + bundleId: .appBundleID(name: ".AudioPlayerService"), + product: .framework, + settings: .settings(), + dependencies: [ + .SPM.composableArchitecture, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Service/AudioPlayerService/Sources/AudioPlayerDependency+Live.swift b/Projects/Service/AudioPlayerService/Sources/AudioPlayerDependency+Live.swift new file mode 100644 index 00000000..884655cd --- /dev/null +++ b/Projects/Service/AudioPlayerService/Sources/AudioPlayerDependency+Live.swift @@ -0,0 +1,13 @@ +// +// AudioPlayerDependency+Live.swift +// AudioPlayerService +// + +import AudioPlayerServiceInterface +import ComposableArchitecture + +// MARK: - Live + +extension AudioPlayerDependency: DependencyKey { + public static var liveValue: AudioPlayerInterface { AudioPlayerRepositoryImpl() } +} diff --git a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift b/Projects/Service/AudioPlayerService/Sources/AudioPlayerRepositoryImpl.swift similarity index 98% rename from Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift rename to Projects/Service/AudioPlayerService/Sources/AudioPlayerRepositoryImpl.swift index 268758a0..d9a46478 100644 --- a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift +++ b/Projects/Service/AudioPlayerService/Sources/AudioPlayerRepositoryImpl.swift @@ -4,7 +4,7 @@ // import AVFoundation -import DomainInterface +import AudioPlayerServiceInterface import Foundation public final class AudioPlayerRepositoryImpl: AudioPlayerInterface, @unchecked Sendable { diff --git a/Projects/Service/AudioPlayerService/Tests/Sources/AudioPlayerServiceTests.swift b/Projects/Service/AudioPlayerService/Tests/Sources/AudioPlayerServiceTests.swift new file mode 100644 index 00000000..d3a54346 --- /dev/null +++ b/Projects/Service/AudioPlayerService/Tests/Sources/AudioPlayerServiceTests.swift @@ -0,0 +1,14 @@ +// +// AudioPlayerServiceTests.swift +// AudioPlayerServiceTests +// + +@testable import AudioPlayerService +import Testing + +struct AudioPlayerServiceTests { + @Test + func audioPlayerServiceExample() { + #expect(true) + } +} diff --git a/Projects/Domain/Entity/Sources/Device/DevicePlatform.swift b/Projects/Service/DeviceService/Interface/Entity/DevicePlatform.swift similarity index 85% rename from Projects/Domain/Entity/Sources/Device/DevicePlatform.swift rename to Projects/Service/DeviceService/Interface/Entity/DevicePlatform.swift index 0d4f997f..aad77fdd 100644 --- a/Projects/Domain/Entity/Sources/Device/DevicePlatform.swift +++ b/Projects/Service/DeviceService/Interface/Entity/DevicePlatform.swift @@ -1,6 +1,6 @@ // // DevicePlatform.swift -// Entity +// DeviceServiceInterface // import Foundation diff --git a/Projects/Domain/DomainInterface/Sources/Device/Interface/DeviceInterface.swift b/Projects/Service/DeviceService/Interface/UseCase/DeviceInterface.swift similarity index 64% rename from Projects/Domain/DomainInterface/Sources/Device/Interface/DeviceInterface.swift rename to Projects/Service/DeviceService/Interface/UseCase/DeviceInterface.swift index 08122553..8b4143d1 100644 --- a/Projects/Domain/DomainInterface/Sources/Device/Interface/DeviceInterface.swift +++ b/Projects/Service/DeviceService/Interface/UseCase/DeviceInterface.swift @@ -1,11 +1,10 @@ // // DeviceInterface.swift -// DomainInterface +// DeviceServiceInterface // -import Entity +import Dependencies import Foundation -import WeaveDI public protocol DeviceInterface: Sendable { /// 로그인 직후 / 토큰 갱신 시 FCM 토큰 등록. @@ -20,16 +19,9 @@ public struct DefaultDeviceRepositoryImpl: DeviceInterface { public func unregisterDevice(fcmToken _: String) async throws {} } -public struct DeviceRepositoryDependency: DependencyKey { - public static var liveValue: DeviceInterface { - UnifiedDI.resolve(DeviceInterface.self) ?? DefaultDeviceRepositoryImpl() - } - - public static var testValue: DeviceInterface { - UnifiedDI.resolve(DeviceInterface.self) ?? DefaultDeviceRepositoryImpl() - } - - public static var previewValue: DeviceInterface = liveValue +/// live 구현(`DeviceRepositoryImpl`)은 DeviceService 가 `DependencyKey` 로 이어 붙인다. +public struct DeviceRepositoryDependency: TestDependencyKey { + public static var testValue: DeviceInterface { DefaultDeviceRepositoryImpl() } } public extension DependencyValues { diff --git a/Projects/Domain/UseCase/Sources/Device/DeviceUseCase.swift b/Projects/Service/DeviceService/Interface/UseCase/DeviceUseCase.swift similarity index 51% rename from Projects/Domain/UseCase/Sources/Device/DeviceUseCase.swift rename to Projects/Service/DeviceService/Interface/UseCase/DeviceUseCase.swift index 8a852162..de8ab051 100644 --- a/Projects/Domain/UseCase/Sources/Device/DeviceUseCase.swift +++ b/Projects/Service/DeviceService/Interface/UseCase/DeviceUseCase.swift @@ -1,24 +1,11 @@ // // DeviceUseCase.swift -// UseCase +// DeviceServiceInterface // import Foundation -import DomainInterface -import Entity - -import ComposableArchitecture - -/// APNs 디바이스 토큰 보관소 (UserDefaults). App(수신)·Presentation(로그아웃 해제) 공용. -public enum DeviceTokenStorage { - private static let key = "PickeDeviceToken" - - public static var token: String? { - get { UserDefaults.standard.string(forKey: key) } - set { UserDefaults.standard.set(newValue, forKey: key) } - } -} +import Dependencies public struct DeviceUseCaseImpl: DeviceInterface { @Dependency(\.deviceRepository) private var deviceRepository @@ -34,10 +21,9 @@ public struct DeviceUseCaseImpl: DeviceInterface { } } -extension DeviceUseCaseImpl: DependencyKey { - public static var liveValue = DeviceUseCaseImpl() - public static var testValue = DeviceUseCaseImpl() - public static var previewValue = DeviceUseCaseImpl() +extension DeviceUseCaseImpl: TestDependencyKey { + public static let testValue = DeviceUseCaseImpl() + public static let previewValue = DeviceUseCaseImpl() } public extension DependencyValues { diff --git a/Projects/Service/DeviceService/Project.swift b/Projects/Service/DeviceService/Project.swift new file mode 100644 index 00000000..acb39f0e --- /dev/null +++ b/Projects/Service/DeviceService/Project.swift @@ -0,0 +1,26 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "DeviceService", + bundleId: .appBundleID(name: ".DeviceService"), + product: .framework, + settings: .settings(), + dependencies: [ + .service(.apiEndpoint), + .core(.network), + .SPM.composableArchitecture, + .core(.logger), + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Service/DeviceService/Sources/Repository/DeviceRepositoryDependency+Live.swift b/Projects/Service/DeviceService/Sources/Repository/DeviceRepositoryDependency+Live.swift new file mode 100644 index 00000000..9c296650 --- /dev/null +++ b/Projects/Service/DeviceService/Sources/Repository/DeviceRepositoryDependency+Live.swift @@ -0,0 +1,13 @@ +// +// DeviceRepositoryDependency+Live.swift +// DeviceService +// + +import ComposableArchitecture +import DeviceServiceInterface + +// MARK: - Live + +extension DeviceRepositoryDependency: DependencyKey { + public static var liveValue: DeviceInterface { DeviceRepositoryImpl() } +} diff --git a/Projects/Service/DeviceService/Sources/Repository/DeviceRepositoryImpl.swift b/Projects/Service/DeviceService/Sources/Repository/DeviceRepositoryImpl.swift new file mode 100644 index 00000000..2bdae4a5 --- /dev/null +++ b/Projects/Service/DeviceService/Sources/Repository/DeviceRepositoryImpl.swift @@ -0,0 +1,33 @@ +// +// DeviceRepositoryImpl.swift +// DeviceService +// + +import Foundation + +import Dependencies + +import APIEndpoint +import PickeNetwork + +import DeviceServiceInterface + +public final class DeviceRepositoryImpl: DeviceInterface, @unchecked Sendable { + @Dependency(\.networkClient) private var client + + public init() {} + + public func registerDevice(fcmToken: String, platform: DevicePlatform) async throws { + _ = try await client.send( + DeviceService.register( body: DeviceRegisterRequest( fcmToken: fcmToken, platform: platform.rawValue ) ), + as: PickeEmptyResponse.self + ) + } + + public func unregisterDevice(fcmToken: String) async throws { + _ = try await client.send( + DeviceService.unregister(fcmToken: fcmToken), + as: PickeEmptyResponse.self + ) + } +} diff --git a/Projects/Service/DeviceService/Sources/UseCase/DeviceUseCase+Live.swift b/Projects/Service/DeviceService/Sources/UseCase/DeviceUseCase+Live.swift new file mode 100644 index 00000000..f59d5cd0 --- /dev/null +++ b/Projects/Service/DeviceService/Sources/UseCase/DeviceUseCase+Live.swift @@ -0,0 +1,15 @@ +// +// DeviceUseCase+Live.swift +// DeviceService +// + +import Foundation + +import ComposableArchitecture +import DeviceServiceInterface + +// MARK: - Live + +extension DeviceUseCaseImpl: DependencyKey { + public static var liveValue = DeviceUseCaseImpl() +} diff --git a/Projects/Service/DeviceService/Tests/Sources/DeviceServiceTests.swift b/Projects/Service/DeviceService/Tests/Sources/DeviceServiceTests.swift new file mode 100644 index 00000000..a6851a45 --- /dev/null +++ b/Projects/Service/DeviceService/Tests/Sources/DeviceServiceTests.swift @@ -0,0 +1,14 @@ +// +// DeviceServiceTests.swift +// DeviceServiceTests +// + +@testable import DeviceService +import Testing + +struct DeviceServiceTests { + @Test + func deviceServiceExample() { + #expect(true) + } +} diff --git a/Projects/Service/PickeAnalytics/Interface/Event/AdClickData.swift b/Projects/Service/PickeAnalytics/Interface/Event/AdClickData.swift new file mode 100644 index 00000000..892d2e2d --- /dev/null +++ b/Projects/Service/PickeAnalytics/Interface/Event/AdClickData.swift @@ -0,0 +1,27 @@ +// +// AdClickData.swift +// UseCase +// + +import Foundation + +/// 광고 노출 형태. 같은 지면이라도 형태가 다르면 클릭 성격이 달라 따로 본다. +public enum AdFormat: String, Sendable { + case banner + case native + case popup + case rewarded +} + +public struct AdClickData: Sendable { + public let placement: AdPlacement + public let format: AdFormat + /// 광고 단위 식별자(Info.plist 키). 지면이 같아도 단위별 성과를 나눠 보기 위해 남긴다. + public let unit: String? + + public init(placement: AdPlacement, format: AdFormat, unit: String? = nil) { + self.placement = placement + self.format = format + self.unit = unit + } +} diff --git a/Projects/Service/PickeAnalytics/Interface/Event/AdPlacement.swift b/Projects/Service/PickeAnalytics/Interface/Event/AdPlacement.swift new file mode 100644 index 00000000..50b0fb03 --- /dev/null +++ b/Projects/Service/PickeAnalytics/Interface/Event/AdPlacement.swift @@ -0,0 +1,22 @@ +// +// AdPlacement.swift +// UseCase +// + +import Foundation + +/// 광고가 놓인 자리. ad_revenue(리워드 시청 완료)와 ad_click(광고 클릭)이 함께 쓴다. +public enum AdPlacement: String, Sendable { + /// 충전소 리워드 광고. 이미 쌓인 ad_revenue 데이터와 값이 끊기지 않도록 표기를 그대로 둔다. + case charge = "충전소" + /// 홈 피드 네이티브. + case home + /// 큐레이션 리스트 최상단 네이티브. + case curation + /// 마이페이지 하단 네이티브. + case mypage + /// 탐색(Hifi) 카드 사이 배너. + case explore + /// 앱 시작 전면 팝업. + case appStart = "app_start" +} diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/AnalyticsButton.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/AnalyticsButton.swift rename to Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsEvent.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift similarity index 93% rename from Projects/Domain/UseCase/Sources/Analytics/AnalyticsEvent.swift rename to Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift index ee6bfb16..8dba8795 100644 --- a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsEvent.swift +++ b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift @@ -16,6 +16,8 @@ public enum AnalyticsEvent: Sendable { case communityAction(CommunityActionData) /// 보상형 광고 시청 완료 시. case adRevenue(placement: AdPlacement) + /// 광고 클릭 시 (배너/네이티브/전면 팝업/리워드 공통). + case adClick(AdClickData) // MARK: 확장 (설계: analytics-mixpanel-design.md) diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/AnalyticsProvider.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsProvider.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/AnalyticsProvider.swift rename to Projects/Service/PickeAnalytics/Interface/Event/AnalyticsProvider.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/AnalyticsScreen.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsScreen.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/AnalyticsScreen.swift rename to Projects/Service/PickeAnalytics/Interface/Event/AnalyticsScreen.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/BattleStepData.swift b/Projects/Service/PickeAnalytics/Interface/Event/BattleStepData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/BattleStepData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/BattleStepData.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/CommunityActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/CommunityActionData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/CommunityActionData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/CommunityActionData.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/ContentActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/ContentActionData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/ContentActionData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/ContentActionData.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/EngagementAction.swift b/Projects/Service/PickeAnalytics/Interface/Event/EngagementAction.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/EngagementAction.swift rename to Projects/Service/PickeAnalytics/Interface/Event/EngagementAction.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/NotificationActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/NotificationActionData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/NotificationActionData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/NotificationActionData.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/OnboardingStep.swift b/Projects/Service/PickeAnalytics/Interface/Event/OnboardingStep.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/OnboardingStep.swift rename to Projects/Service/PickeAnalytics/Interface/Event/OnboardingStep.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/PlaybackAction.swift b/Projects/Service/PickeAnalytics/Interface/Event/PlaybackAction.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/PlaybackAction.swift rename to Projects/Service/PickeAnalytics/Interface/Event/PlaybackAction.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/PointActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/PointActionData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/PointActionData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/PointActionData.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/ReportActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/ReportActionData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/ReportActionData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/ReportActionData.swift diff --git a/Projects/Domain/UseCase/Sources/Analytics/Events/ShareActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/ShareActionData.swift similarity index 100% rename from Projects/Domain/UseCase/Sources/Analytics/Events/ShareActionData.swift rename to Projects/Service/PickeAnalytics/Interface/Event/ShareActionData.swift diff --git a/Projects/Service/PickeAnalytics/Interface/UseCase/AnalyticsUseCase.swift b/Projects/Service/PickeAnalytics/Interface/UseCase/AnalyticsUseCase.swift new file mode 100644 index 00000000..2f04e2b6 --- /dev/null +++ b/Projects/Service/PickeAnalytics/Interface/UseCase/AnalyticsUseCase.swift @@ -0,0 +1,52 @@ +// +// AnalyticsUseCase.swift +// PickeAnalytics +// + +import Foundation + +import ComposableArchitecture + +// MARK: - UseCase + +/// 유저 액션 트래킹 계약. +public struct AnalyticsUseCase: Sendable { + /// 앱 시작 시 공통 슈퍼 프로퍼티(os_type/app_version/build) 등록. + public var registerBaseProperties: @Sendable () -> Void + /// 로그인 성공 직후 유저 고유 ID 연결 + 로그인 슈퍼/유저 프로퍼티 설정. + public var identify: @Sendable (_ userID: String, _ method: String?) -> Void + /// 핵심 퍼널 이벤트 트래킹. + public var track: @Sendable (_ event: AnalyticsEvent) -> Void + /// 로그아웃/탈퇴 시 계정 분리(reset) + 공통 프로퍼티 재등록. + public var reset: @Sendable () -> Void + + public init( + registerBaseProperties: @escaping @Sendable () -> Void, + identify: @escaping @Sendable (_ userID: String, _ method: String?) -> Void, + track: @escaping @Sendable (_ event: AnalyticsEvent) -> Void, + reset: @escaping @Sendable () -> Void + ) { + self.registerBaseProperties = registerBaseProperties + self.identify = identify + self.track = track + self.reset = reset + } +} + +/// 테스트/프리뷰 기본값은 인터페이스가 갖는다. `liveValue` 는 구현 모듈이 `DependencyKey` 로 채운다 +extension AnalyticsUseCase: TestDependencyKey { + public static let testValue = AnalyticsUseCase( + registerBaseProperties: {}, + identify: { _, _ in }, + track: { _ in }, + reset: {} + ) + public static let previewValue = testValue +} + +public extension DependencyValues { + var analyticsUseCase: AnalyticsUseCase { + get { self[AnalyticsUseCase.self] } + set { self[AnalyticsUseCase.self] = newValue } + } +} diff --git a/Projects/Service/PickeAnalytics/Project.swift b/Projects/Service/PickeAnalytics/Project.swift new file mode 100644 index 00000000..8c751dfb --- /dev/null +++ b/Projects/Service/PickeAnalytics/Project.swift @@ -0,0 +1,28 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeAnalytics", + bundleId: .appBundleID(name: ".PickeAnalytics"), + product: .framework, + settings: .settings(), + dependencies: [ + .core(.logger), + .core(.network), + .SPM.mixpanel, + .SPM.mixpanelSessionReplay, + .SPM.sentry, + .SPM.sentrySwiftUI, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + ], + hasTesting: false +) \ No newline at end of file diff --git a/Projects/Service/PickeAnalytics/Sources/Configuration/MixpanelConfiguration.swift b/Projects/Service/PickeAnalytics/Sources/Configuration/MixpanelConfiguration.swift new file mode 100644 index 00000000..097d6c0f --- /dev/null +++ b/Projects/Service/PickeAnalytics/Sources/Configuration/MixpanelConfiguration.swift @@ -0,0 +1,74 @@ +// +// MixpanelConfiguration.swift +// PickeAnalytics +// + +import Foundation + +import PickeCoreLogger +import Mixpanel +import MixpanelSessionReplay +import PickeNetwork + +/// Mixpanel 기동과 공통 프로퍼티·세션 리플레이·네트워크 텔레메트리 연결. +enum MixpanelConfiguration { + private static var token: String? { + Bundle.main.object(forInfoDictionaryKey: "MIXPANEL_TOKEN") as? String + } + + static func configure() { + let token = token + PickeLogger.debug( + "Mixpanel initialize — token_exists: \(!(token?.isEmpty ?? true)), token_prefix: \(String((token ?? "").prefix(6)))", + category: .app + ) + Mixpanel.initialize(token: token ?? "", trackAutomaticEvents: true) + + // 모든 이벤트에 자동 첨부되는 공통 슈퍼 프로퍼티(플랫폼/버전). is_logged_in 은 로그인/로그아웃이 관리. + let info = Bundle.main.infoDictionary + Mixpanel.mainInstance().registerSuperProperties([ + "os_type": "ios", + "app_version": (info?["CFBundleShortVersionString"] as? String) ?? "", + "build": (info?["CFBundleVersion"] as? String) ?? "", + ]) + + configureSessionReplay(token: token) + configureNetworkTelemetry() + } + + /// NOTE: MixpanelSessionReplay 1.4.0의 _UIReparentingView swizzling이 + /// SwiftUI UIHostingController.view에 적용되며 콘솔 경고가 출력될 수 있음 (기능 영향 없음). + /// SDK 측 SwiftUI 호환성 개선 시 경고 자동 해소 예정. + private static func configureSessionReplay(token: String?) { + guard !(token?.isEmpty ?? true) else { return } + + var config = MPSessionReplayConfig(wifiOnly: false) + config.enableSessionReplayOniOS26AndLater = true + + MPSessionReplay.initialize( + token: Mixpanel.mainInstance().apiToken, + distinctId: Mixpanel.mainInstance().distinctId, + config: config + ) + } + + private static func configureNetworkTelemetry() { + NetworkTelemetry.shared.configure { event in + var properties: Properties = [ + "source": event.source, + "method": event.method, + "host": event.host, + "path": event.path, + "duration_ms": event.durationMilliseconds, + "success": event.isSuccess, + ] + if let statusCode = event.statusCode { + properties["status_code"] = statusCode + } + Mixpanel.mainInstance().track( + event: "network_request", + properties: properties + ) + } + } +} diff --git a/Projects/Service/PickeAnalytics/Sources/Configuration/PickeAnalyticsConfiguration.swift b/Projects/Service/PickeAnalytics/Sources/Configuration/PickeAnalyticsConfiguration.swift new file mode 100644 index 00000000..df3854e0 --- /dev/null +++ b/Projects/Service/PickeAnalytics/Sources/Configuration/PickeAnalyticsConfiguration.swift @@ -0,0 +1,14 @@ +// +// PickeAnalyticsConfiguration.swift +// PickeAnalytics +// + +/// 관측 SDK 기동 진입점. 순서에 의미가 있다. +/// +/// Sentry 를 가장 먼저 올려야 이후 초기화 중 발생한 크래시까지 포착된다. +public enum PickeAnalyticsConfiguration { + public static func configure() { + SentryConfiguration.configure() + MixpanelConfiguration.configure() + } +} diff --git a/Projects/App/Sources/Application/Delegate/AppDelegate+Sentry.swift b/Projects/Service/PickeAnalytics/Sources/Configuration/SentryConfiguration.swift similarity index 93% rename from Projects/App/Sources/Application/Delegate/AppDelegate+Sentry.swift rename to Projects/Service/PickeAnalytics/Sources/Configuration/SentryConfiguration.swift index 729dff0a..c1c75e23 100644 --- a/Projects/App/Sources/Application/Delegate/AppDelegate+Sentry.swift +++ b/Projects/Service/PickeAnalytics/Sources/Configuration/SentryConfiguration.swift @@ -1,21 +1,23 @@ // -// AppDelegate+Sentry.swift -// Picke +// SentryConfiguration.swift +// PickeAnalytics // import Foundation -import LogMacro + +import PickeCoreLogger @preconcurrency import Sentry -extension AppDelegate { +/// Sentry 기동과 런치 메트릭 전송. +enum SentryConfiguration { /// 가장 먼저 호출해 초기 크래시까지 포착한다. /// DSN·환경은 xcconfig → Info.plist 로 주입된 값을 읽는다(BASE_URL/MIXPANEL_TOKEN 과 동일 패턴). - func configureSentry() { + static func configure() { let info = Bundle.main.infoDictionary // SENTRY_DSN 은 xcconfig 에서 스킴(https://)을 제외하고 저장 → 코드에서 붙인다. let dsnHost = (info?["SENTRY_DSN"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "" guard !dsnHost.isEmpty else { - #logError("[Sentry] SENTRY_DSN 미설정 — 초기화 스킵") + PickeLogger.error("[Sentry] SENTRY_DSN 미설정 — 초기화 스킵", category: .app) return } let environment = (info?["SENTRY_ENVIRONMENT"] as? String)? @@ -96,7 +98,7 @@ extension AppDelegate { /// 앱 실행 시 다양한 기기/런타임 메트릭을 환경 태깅해 전송한다(모든 빌드). /// 런치당 1회라 볼륨이 낮고, 카운트·게이지로 지표 유형을 다양화했다. - private func emitLaunchMetrics(environment: String) { + private static func emitLaunchMetrics(environment: String) { let process = ProcessInfo.processInfo let attributes: [String: any SentryAttributeValue] = [ "environment": environment, @@ -126,7 +128,7 @@ extension AppDelegate { } /// Sentry 온보딩 검증용 로그/메트릭. DEBUG(Stage) 빌드에서만 전송해 운영 데이터 오염을 막는다. - func sendSentryVerificationTelemetry(environment: String) { + private static func sendSentryVerificationTelemetry(environment: String) { #if DEBUG let logAttributes: [String: Any] = [ "log_type": "test", diff --git a/Projects/Service/PickeAnalytics/Sources/Trace/PickeTracedView.swift b/Projects/Service/PickeAnalytics/Sources/Trace/PickeTracedView.swift new file mode 100644 index 00000000..2f19c007 --- /dev/null +++ b/Projects/Service/PickeAnalytics/Sources/Trace/PickeTracedView.swift @@ -0,0 +1,27 @@ +// +// PickeTracedView.swift +// PickeAnalytics +// + +import SwiftUI + +import SentrySwiftUI + +/// 화면 렌더 구간을 Sentry 트랜잭션으로 감싸는 래퍼. +/// +/// SentrySwiftUI 를 여기서만 링크해 앱·피처는 관측 SDK 를 모르게 둔다. +public struct PickeTracedView: View { + private let name: String + private let content: () -> Content + + public init(_ name: String, @ViewBuilder content: @escaping () -> Content) { + self.name = name + self.content = content + } + + public var body: some View { + SentryTracedView(name) { + content() + } + } +} diff --git a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift b/Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift similarity index 65% rename from Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift rename to Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift index 51181e53..eb6f0173 100644 --- a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift +++ b/Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift @@ -1,40 +1,18 @@ // -// AnalyticsUseCase.swift -// UseCase +// AnalyticsUseCase+Live.swift +// PickeAnalytics // import Foundation +import PickeAnalyticsInterface import ComposableArchitecture -import Entity -import LogMacro +import PickeCoreLogger import Mixpanel import MixpanelSessionReplay +@preconcurrency import Sentry -// MARK: - UseCase - -public struct AnalyticsUseCase: Sendable { - /// 앱 시작 시 공통 슈퍼 프로퍼티(os_type/app_version/build) 등록. - public var registerBaseProperties: @Sendable () -> Void - /// 로그인 성공 직후 유저 고유 ID 연결 + 로그인 슈퍼/유저 프로퍼티 설정. - public var identify: @Sendable (_ userID: String, _ method: String?) -> Void - /// 핵심 퍼널 이벤트 트래킹. - public var track: @Sendable (_ event: AnalyticsEvent) -> Void - /// 로그아웃/탈퇴 시 계정 분리(reset) + 공통 프로퍼티 재등록. - public var reset: @Sendable () -> Void - - public init( - registerBaseProperties: @escaping @Sendable () -> Void, - identify: @escaping @Sendable (_ userID: String, _ method: String?) -> Void, - track: @escaping @Sendable (_ event: AnalyticsEvent) -> Void, - reset: @escaping @Sendable () -> Void - ) { - self.registerBaseProperties = registerBaseProperties - self.identify = identify - self.track = track - self.reset = reset - } -} +// MARK: - Live extension AnalyticsUseCase: DependencyKey { /// 모든 이벤트에 자동 첨부되는 공통 슈퍼 프로퍼티(플랫폼/버전). is_logged_in 은 identify/reset 이 관리. @@ -72,28 +50,77 @@ extension AnalyticsUseCase: DependencyKey { if !properties.isEmpty { mixpanel.people.set(properties: properties) } + + // Sentry 이슈에도 같은 유저를 붙인다 — 크래시가 누구에게 터졌는지 Mixpanel 과 동일 ID 로 대조된다. + let user = User(userId: userID) + if let method, !method.isEmpty { + user.data = ["login_provider": method] + } + SentrySDK.setUser(user) }, track: { event in let mixpanel = Mixpanel.mainInstance() let name = eventName(event) let properties = eventProperties(event) - #logDebug("Mixpanel track", ["event": name, "properties": String(describing: properties)]) + PickeLogger.debug("Mixpanel track — event: \(name), properties: \(String(describing: properties))", category: .app) mixpanel.track(event: name, properties: properties) + enrichPeopleProfile(mixpanel, event: event) + monitorInSentry(name: name, properties: properties, event: event) }, reset: { let mixpanel = Mixpanel.mainInstance() mixpanel.reset() // 슈퍼 프로퍼티 포함 전체 초기화 → 계정 분리. mixpanel.registerSuperProperties(baseSuperProperties()) // 공통 프로퍼티 재등록(is_logged_in 미포함 = 로그아웃). + SentrySDK.setUser(nil) // 다음 이슈가 이전 계정에 붙지 않도록 함께 끊는다. } ) - public static let testValue = AnalyticsUseCase( - registerBaseProperties: {}, - identify: { _, _ in }, - track: { _ in }, - reset: {} - ) - public static let previewValue = testValue + /// 트래킹한 유저 액션을 Sentry 에도 남긴다. 셋 다 목적이 다르다. + private static func monitorInSentry(name: String, properties: Properties, event: AnalyticsEvent) { + let attributes = properties.compactMapValues { $0 as? String } + + let breadcrumb = Breadcrumb(level: .info, category: "user.action") + breadcrumb.message = name + breadcrumb.data = properties.mapValues { $0 as Any } + SentrySDK.addBreadcrumb(breadcrumb) + + var metricAttributes: [String: any SentryAttributeValue] = ["event": name] + for (key, value) in attributes { + metricAttributes[key] = value + } + SentrySDK.metrics.count(key: "user.action.count", value: 1, attributes: metricAttributes) + + // 광고 클릭은 매출과 직결돼 별도 카운터 + 로그로 남긴다(로그는 검색/필터 대상). + guard case let .adClick(data) = event else { return } + SentrySDK.metrics.count( + key: "ad.click.count", + value: 1, + attributes: [ + "placement": data.placement.rawValue, + "format": data.format.rawValue, + ] + ) + SentrySDK.logger.info("ad_click", attributes: metricAttributes) + } + + /// Mixpanel 유저 프로필 누적. 이벤트만으론 "이 유저가 광고를 얼마나 누르는 사람인지" 를 + private static func enrichPeopleProfile(_ mixpanel: MixpanelInstance, event: AnalyticsEvent) { + switch event { + case let .adClick(data): + mixpanel.people.increment(property: "ad_click_count", by: 1) + mixpanel.people.set(properties: [ + "last_ad_click_at": Date(), + "last_ad_placement": data.placement.rawValue, + ]) + + case let .adRevenue(placement): + mixpanel.people.increment(property: "ad_reward_count", by: 1) + mixpanel.people.set(properties: ["last_ad_reward_placement": placement.rawValue]) + + default: + break + } + } private static func eventName(_ event: AnalyticsEvent) -> String { switch event { @@ -102,6 +129,7 @@ extension AnalyticsUseCase: DependencyKey { case .reportAction: "report_action" case .communityAction: "community_action" case .adRevenue: "ad_revenue" + case .adClick: "ad_click" case .onboardingStep: "onboarding_step" case .pointAction: "point_action" case .notificationAction: "notification_action" @@ -148,6 +176,16 @@ extension AnalyticsUseCase: DependencyKey { case let .adRevenue(placement): return ["placement": placement.rawValue] + case let .adClick(data): + var properties: Properties = [ + "placement": data.placement.rawValue, + "format": data.format.rawValue, + ] + if let unit = data.unit { + properties["unit"] = unit + } + return properties + case let .onboardingStep(step, provider): var properties: Properties = ["step": step.rawValue] if let provider { @@ -207,10 +245,3 @@ extension AnalyticsUseCase: DependencyKey { } } } - -public extension DependencyValues { - var analyticsUseCase: AnalyticsUseCase { - get { self[AnalyticsUseCase.self] } - set { self[AnalyticsUseCase.self] = newValue } - } -} diff --git a/Projects/Service/PickeAnalytics/Tests/Sources/PickeAnalyticsTests.swift b/Projects/Service/PickeAnalytics/Tests/Sources/PickeAnalyticsTests.swift new file mode 100644 index 00000000..6a7f2cf9 --- /dev/null +++ b/Projects/Service/PickeAnalytics/Tests/Sources/PickeAnalyticsTests.swift @@ -0,0 +1,14 @@ +// +// PickeAnalyticsTests.swift +// PickeAnalyticsTests +// + +@testable import PickeAnalytics +import Testing + +struct PickeAnalyticsTests { + @Test + func analyticsServiceExample() { + #expect(true) + } +} diff --git a/Projects/Service/PickeAuth/Interface/AuthService.swift b/Projects/Service/PickeAuth/Interface/AuthService.swift new file mode 100644 index 00000000..dfbfc9b4 --- /dev/null +++ b/Projects/Service/PickeAuth/Interface/AuthService.swift @@ -0,0 +1,24 @@ +// +// AuthService.swift +// PickeAuthInterface +// + +import Foundation + +/// 앱의 인증 상태를 저장소와 네트워크 세션에 동시에 반영하는 단일 진입점. +public protocol AuthService: Sendable { + /// 저장소에 유효한 access/refresh token 쌍이 있는지 확인한다. + var isLoggedIn: Bool { get async } + /// 저장된 refresh token 을 반환한다. + var refreshToken: String? { get async } + + /// 로그인 성공 토큰을 영속화하고 이후 인증 요청에 즉시 반영한다. + func signIn(accessToken: String, refreshToken: String) async + /// 영속 토큰과 현재 네트워크 세션 credential 을 함께 제거한다. + func signOut() async +} + +public extension Notification.Name { + /// refresh token 이 서버에서 거부되어 재로그인이 필요할 때 발행된다. + static let pickeAuthSessionDidExpire = Notification.Name("pickeauth.session.didExpire") +} diff --git a/Projects/Service/PickeAuth/Interface/AuthServiceDependency.swift b/Projects/Service/PickeAuth/Interface/AuthServiceDependency.swift new file mode 100644 index 00000000..b40c0a7e --- /dev/null +++ b/Projects/Service/PickeAuth/Interface/AuthServiceDependency.swift @@ -0,0 +1,54 @@ +// +// AuthServiceDependency.swift +// PickeAuthInterface +// +// 인증 서비스의 DependencyKey. +// +// liveValue 는 Keychain·네트워크 세션을 함께 다루므로 상위 조립 레이어가 등록한다. +// 여기서는 계약과 테스트값만 둔다. +// + +import Foundation + +import Dependencies + +public enum AuthServiceDependency: TestDependencyKey { + public static var testValue: any AuthService { + UnimplementedAuthService() + } +} + +public extension DependencyValues { + var authService: any AuthService { + get { self[AuthServiceDependency.self] } + set { self[AuthServiceDependency.self] = newValue } + } +} + +/// 등록하지 않은 채 인증을 건드리면 알려주는 기본값. +public struct UnimplementedAuthService: AuthService { + public init() {} + + public var isLoggedIn: Bool { + get async { reportUnimplemented() } + } + + public var refreshToken: String? { + get async { reportUnimplemented() } + } + + public func signIn(accessToken _: String, refreshToken _: String) async { + reportUnimplemented() + } + + public func signOut() async { + reportUnimplemented() + } + + private func reportUnimplemented() -> Never { + fatalError( + "authService 가 등록되지 않았다. 테스트라면 withDependencies 로 스텁을 넣고, " + + "앱이라면 ServiceAssembly 의 liveValue 등록을 확인할 것." + ) + } +} diff --git a/Projects/Service/PickeAuth/Interface/AuthenticatedClientProvider.swift b/Projects/Service/PickeAuth/Interface/AuthenticatedClientProvider.swift new file mode 100644 index 00000000..8868eb54 --- /dev/null +++ b/Projects/Service/PickeAuth/Interface/AuthenticatedClientProvider.swift @@ -0,0 +1,12 @@ +// +// AuthenticatedClientProvider.swift +// PickeAuthInterface +// + +import PickeNetworkInterface + +/// `AuthService` 와 같은 생명주기로 조립된 인증 네트워크 클라이언트를 제공한다. +public protocol AuthenticatedClientProvider: Sendable { + /// 현재 인증 저장소와 토큰 재발급기를 공유하는 네트워크 클라이언트다. + var authenticatedClient: any PickeNetworkClient { get } +} diff --git a/Projects/Service/PickeAuth/Project.swift b/Projects/Service/PickeAuth/Project.swift new file mode 100644 index 00000000..b30622dd --- /dev/null +++ b/Projects/Service/PickeAuth/Project.swift @@ -0,0 +1,28 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeAuth", + bundleId: .appBundleID(name: ".PickeAuth"), + product: .framework, + settings: .settings(), + dependencies: [ + .service(.apiEndpoint), + .core(.network), + .core(.storage), + .core(.storage, .interface), + .core(.logger), + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .core(.network, .interface), + .SPM.composableArchitecture, + ], + hasTesting: true +) \ No newline at end of file diff --git a/Projects/Service/PickeAuth/Sources/Factory/AuthFactory.swift b/Projects/Service/PickeAuth/Sources/Factory/AuthFactory.swift new file mode 100644 index 00000000..129daa59 --- /dev/null +++ b/Projects/Service/PickeAuth/Sources/Factory/AuthFactory.swift @@ -0,0 +1,57 @@ +// +// AuthFactory.swift +// PickeAuth +// + +import Foundation +import os + +import PickeAuthInterface +import PickeNetwork +import PickeNetworkInterface +import PickeStorageInterface + +/// 보안 저장소, refresh 클라이언트, 인증 클라이언트를 하나의 인증 서비스로 조립한다. +public enum AuthFactory { + /// refresh 전용(비인증) 클라이언트와 보안 저장소로 완성된 인증 서비스를 생성한다. + public static func make( + refreshClient: any PickeNetworkClient = NetworkClientFactory.plain(), + storage: any SecureStorage + ) -> any AuthService & AuthenticatedClientProvider { + let store = GuardedCredentialStore(base: KeychainCredentialStore(storage: storage)) + let relay = AuthFailureRelay() + let authenticated = NetworkClientFactory.unified( + store: store, + refresher: TokenRefresher(client: refreshClient) { + await relay.fire() + } + ) + let auth = PickeAuth( + authenticatedClient: authenticated.client, + store: store, + credentials: authenticated.credentials + ) + relay.set { [weak auth] in + await auth?.handleAuthFailure() + } + return auth + } +} + +private final class AuthFailureRelay: Sendable { + /// 동기 Factory 조립 중 나중에 생성되는 PickeAuth 실패 핸들러를 연결한다. + private let handler = OSAllocatedUnfairLock<(@Sendable () async -> Void)?>(initialState: nil) + + /// PickeAuth 생성 이후 refresh 실패 핸들러를 원자적으로 등록한다. + func set(_ handler: @escaping @Sendable () async -> Void) { + self.handler.withLock { + $0 = handler + } + } + + /// 등록된 실패 핸들러를 lock 밖에서 비동기로 실행한다. + func fire() async { + let handler = handler.withLock { $0 } + await handler?() + } +} diff --git a/Projects/Service/PickeAuth/Sources/PickeAuth.swift b/Projects/Service/PickeAuth/Sources/PickeAuth.swift new file mode 100644 index 00000000..d4e243e9 --- /dev/null +++ b/Projects/Service/PickeAuth/Sources/PickeAuth.swift @@ -0,0 +1,65 @@ +// +// PickeAuth.swift +// PickeAuth +// + +import Foundation + +import PickeAuthInterface +import PickeNetworkInterface + +/// 토큰 저장소와 네트워크 인증 세션의 상태를 하나의 생명주기로 관리한다. +actor PickeAuth: AuthService, AuthenticatedClientProvider { + /// 인증 상태와 함께 조립되지만 actor 격리 없이 안전하게 공유할 수 있는 불변 클라이언트다. + nonisolated let authenticatedClient: any PickeNetworkClient + + /// 로그인·로그아웃과 refresh 저장 간 경쟁 상태를 차단하는 credential 저장소다. + private let store: GuardedCredentialStore + /// 실행 중인 네트워크 세션에 최신 credential 을 반영하는 갱신기다. + private let credentials: any CredentialUpdating + + /// 동일한 저장소와 네트워크 세션을 하나의 인증 생명주기로 묶는다. + init( + authenticatedClient: any PickeNetworkClient, + store: GuardedCredentialStore, + credentials: any CredentialUpdating + ) { + self.authenticatedClient = authenticatedClient + self.store = store + self.credentials = credentials + } + + /// 저장소에 사용할 수 있는 credential 이 남아 있는지 반환한다. + var isLoggedIn: Bool { + store.load() != nil + } + + var refreshToken: String? { + store.load()?.refreshToken + } + + /// 새 로그인 토큰을 저장하고 이후 인증 요청에 즉시 반영한다. + func signIn(accessToken: String, refreshToken: String) { + store.allowSaves() + let credential = PickeCredential( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: JWTDecoder.decodeExpiration(accessToken) + ) + store.save(credential) + credentials.update(credential) + } + + /// 영속 credential 과 실행 중인 네트워크 세션을 함께 비운다. + func signOut() { + store.clear() + credentials.update(nil) + } + + /// refresh token 거부 시 세션을 정리하고 App 계층에 만료 이벤트를 전달한다. + func handleAuthFailure() { + signOut() + // 화면 전환은 App 계층의 책임이므로 서비스는 세션 정리 후 이벤트만 알린다. + NotificationCenter.default.post(name: .pickeAuthSessionDidExpire, object: nil) + } +} diff --git a/Projects/Service/PickeAuth/Sources/Refresh/TokenRefresher.swift b/Projects/Service/PickeAuth/Sources/Refresh/TokenRefresher.swift new file mode 100644 index 00000000..ade9ae31 --- /dev/null +++ b/Projects/Service/PickeAuth/Sources/Refresh/TokenRefresher.swift @@ -0,0 +1,69 @@ +// +// TokenRefresher.swift +// PickeAuth +// + +import Foundation + +import APIEndpoint +import PickeNetworkInterface + +/// 인증 인터셉터가 401 또는 만료 임박 시 호출하는 토큰 갱신기. +struct TokenRefresher: TokenRefreshing { + /// 인증 헤더 없이 refresh endpoint 를 호출하는 요청 클라이언트다. + private let client: any PickeRequestClient + /// 서버가 credential 을 거부했을 때 인증 세션을 종료하는 콜백이다. + private let onAuthFailure: @Sendable () async -> Void + + /// refresh 요청과 인증 실패 후처리를 한 갱신기로 조립한다. + init( + client: any PickeRequestClient, + onAuthFailure: @escaping @Sendable () async -> Void + ) { + self.client = client + self.onAuthFailure = onAuthFailure + } + + /// 현재 refresh token 을 새 credential 쌍으로 교환한다. + func refresh(_ current: PickeCredential) async throws(PickeNetworkError) -> PickeCredential { + do { + let response = try await client.send( + AuthService.refresh(refreshToken: current.refreshToken), + as: RefreshTokenResponse.self + ) + return PickeCredential( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + expiresAt: JWTDecoder.decodeExpiration(response.accessToken) + ) + } catch { + if Self.shouldEndSession(for: error) { + await onAuthFailure() + } + throw error + } + } + + /// 네트워크 오류를 세션 종료 여부에 따라 인증 실패와 일시 실패로 구분한다. + private static func shouldEndSession(for error: PickeNetworkError) -> Bool { + switch error { + case let .response(response): + // 5xx 는 서버 장애, 그 외 응답 오류는 토큰 거부로 구분한다. + return !response.isServerError + case .request, .transport, .decoding: + return false + } + } +} + +private struct RefreshTokenResponse: Decodable, Sendable { + /// refresh 응답에서 받은 새 access token 이다. + let accessToken: String + /// 다음 갱신에 사용할 새 refresh token 이다. + let refreshToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + } +} diff --git a/Projects/Service/PickeAuth/Sources/Store/GuardedCredentialStore.swift b/Projects/Service/PickeAuth/Sources/Store/GuardedCredentialStore.swift new file mode 100644 index 00000000..eeacd914 --- /dev/null +++ b/Projects/Service/PickeAuth/Sources/Store/GuardedCredentialStore.swift @@ -0,0 +1,62 @@ +// +// GuardedCredentialStore.swift +// PickeAuth +// + +import Foundation +import PickeCoreLogger +import os + +import PickeNetworkInterface + +/// 로그아웃 뒤 이미 진행 중이던 refresh 가 완료되어 죽은 세션을 되살리는 race 를 차단한다. +final class GuardedCredentialStore: CredentialStore { + /// 실제 credential 영속화를 담당하는 저장소다. + private let base: any CredentialStore + /// 로그아웃 후 늦게 도착한 refresh 저장 허용 여부를 원자적으로 관리한다. + private let isBarred: OSAllocatedUnfairLock + + /// 기존 credential 유무를 기준으로 최초 저장 허용 상태를 결정한다. + init(base: any CredentialStore) { + self.base = base + isBarred = OSAllocatedUnfairLock(initialState: base.load() == nil) + } + + /// 다른 저장 연산과 직렬화하여 현재 credential 을 읽는다. + func load() -> PickeCredential? { + isBarred.withLock { _ in + base.load() + } + } + + /// 저장이 허용된 세션에서만 새 credential 을 영속화한다. + func save(_ credential: PickeCredential) { + let didSave = isBarred.withLock { isBarred in + guard isBarred == false else { + return false + } + base.save(credential) + return true + } + + guard didSave else { + PickeLogger.debug("signOut 이후 도착한 credential save 무시", category: .auth) + return + } + } + + /// credential 을 제거하고 명시적인 새 로그인 전까지 후속 저장을 차단한다. + func clear() { + isBarred.withLock { isBarred in + isBarred = true + base.clear() + } + } + + /// 새 로그인 시작 시 credential 저장 차단을 해제한다. + func allowSaves() { + isBarred.withLock { isBarred in + isBarred = false + } + } +} diff --git a/Projects/Service/PickeAuth/Sources/Store/KeychainCredentialStore.swift b/Projects/Service/PickeAuth/Sources/Store/KeychainCredentialStore.swift new file mode 100644 index 00000000..0299d00b --- /dev/null +++ b/Projects/Service/PickeAuth/Sources/Store/KeychainCredentialStore.swift @@ -0,0 +1,45 @@ +// +// KeychainCredentialStore.swift +// PickeAuth +// + +import PickeNetworkInterface +import PickeStorageInterface + +/// 범용 `SecureStorage` 를 PickeNetwork 의 credential 저장 계약으로 변환한다. +final class KeychainCredentialStore: CredentialStore { + /// access token 과 refresh token 을 보관하는 보안 저장소다. + private let storage: any SecureStorage + + /// 범용 보안 저장소를 credential 저장소로 감싼다. + init(storage: any SecureStorage) { + self.storage = storage + } + + /// 저장된 토큰 쌍이 모두 유효할 때만 credential 을 복원한다. + func load() -> PickeCredential? { + guard + let accessToken = try? storage.load(.accessToken), !accessToken.isEmpty, + let refreshToken = try? storage.load(.refreshToken), !refreshToken.isEmpty + else { + return nil + } + + return PickeCredential( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: JWTDecoder.decodeExpiration(accessToken) + ) + } + + /// access token 과 refresh token 을 각각의 보안 키로 저장한다. + func save(_ credential: PickeCredential) { + try? storage.save(credential.accessToken, for: .accessToken) + try? storage.save(credential.refreshToken, for: .refreshToken) + } + + /// 인증 종료 시 보안 저장소의 모든 인증 값을 제거한다. + func clear() { + try? storage.removeAll() + } +} diff --git a/Projects/Service/PickeAuth/Sources/Token/JWTDecoder.swift b/Projects/Service/PickeAuth/Sources/Token/JWTDecoder.swift new file mode 100644 index 00000000..362fdfe0 --- /dev/null +++ b/Projects/Service/PickeAuth/Sources/Token/JWTDecoder.swift @@ -0,0 +1,28 @@ +// +// JWTDecoder.swift +// PickeAuth +// + +import Foundation + +/// 서명 검증이 아니라 access token 의 선제 refresh 시각 계산에 필요한 `exp` 만 읽는다. +enum JWTDecoder { + /// JWT payload 의 `exp` 값을 만료 시각으로 변환하고 형식이 잘못되면 nil 을 반환한다. + static func decodeExpiration(_ token: String) -> Date? { + let segments = token.split(separator: ".") + guard segments.count > 1 else { return nil } + + var payload = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + payload += String(repeating: "=", count: (4 - payload.count % 4) % 4) + + guard let data = Data(base64Encoded: payload), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let expiration = object["exp"] as? TimeInterval + else { + return nil + } + return Date(timeIntervalSince1970: expiration) + } +} diff --git a/Projects/Service/PickeAuth/Testing/PickeAuthTesting.swift b/Projects/Service/PickeAuth/Testing/PickeAuthTesting.swift new file mode 100644 index 00000000..1a9b407a --- /dev/null +++ b/Projects/Service/PickeAuth/Testing/PickeAuthTesting.swift @@ -0,0 +1,38 @@ +// +// PickeAuthTesting.swift +// PickeAuthTesting +// + +import Foundation + +import PickeAuthInterface + +/// 인증 상태만 메모리에 들고 있는 테스트 더블. +/// 저장소·네트워크 세션을 건드리지 않아 Repository 테스트에서 그대로 주입할 수 있다. +public actor StubAuthService: AuthService { + private var accessToken: String? + private var storedRefreshToken: String? + + public init(accessToken: String? = nil, refreshToken: String? = nil) { + self.accessToken = accessToken + storedRefreshToken = refreshToken + } + + public var isLoggedIn: Bool { + accessToken != nil && storedRefreshToken != nil + } + + public var refreshToken: String? { + storedRefreshToken + } + + public func signIn(accessToken: String, refreshToken: String) async { + self.accessToken = accessToken + storedRefreshToken = refreshToken + } + + public func signOut() async { + accessToken = nil + storedRefreshToken = nil + } +} diff --git a/Projects/Service/PickeAuth/Tests/Sources/KeychainCredentialStoreTests.swift b/Projects/Service/PickeAuth/Tests/Sources/KeychainCredentialStoreTests.swift new file mode 100644 index 00000000..100debf9 --- /dev/null +++ b/Projects/Service/PickeAuth/Tests/Sources/KeychainCredentialStoreTests.swift @@ -0,0 +1,56 @@ +// +// KeychainCredentialStoreTests.swift +// PickeAuthTests +// + +import Foundation +import Testing + +@testable import PickeAuth +import PickeNetworkInterface +import PickeStorageInterface + +struct KeychainCredentialStoreTests { + @Test + func 토큰쌍이_모두_있으면_credential_을_복원한다() throws { + let storage = FakeSecureStorage(values: [ + .accessToken: "access", + .refreshToken: "refresh" + ]) + + let credential = try #require(KeychainCredentialStore(storage: storage).load()) + + #expect(credential.accessToken == "access") + #expect(credential.refreshToken == "refresh") + } + + @Test + func 토큰이_하나라도_비면_credential_을_복원하지_않는다() { + let storage = FakeSecureStorage(values: [.accessToken: "access"]) + + #expect(KeychainCredentialStore(storage: storage).load() == nil) + } + + @Test + func credential_을_저장하면_토큰쌍이_보관된다() { + let storage = FakeSecureStorage() + let sut = KeychainCredentialStore(storage: storage) + + sut.save(PickeCredential(accessToken: "new-access", refreshToken: "new-refresh")) + + #expect(storage.values[.accessToken] == "new-access") + #expect(storage.values[.refreshToken] == "new-refresh") + } + + @Test + func clear_는_보관된_토큰을_모두_지운다() { + let storage = FakeSecureStorage(values: [ + .accessToken: "access", + .refreshToken: "refresh" + ]) + + KeychainCredentialStore(storage: storage).clear() + + #expect(storage.values.isEmpty) + } +} diff --git a/Projects/Service/PickeAuth/Tests/Sources/PickeAuthTests.swift b/Projects/Service/PickeAuth/Tests/Sources/PickeAuthTests.swift new file mode 100644 index 00000000..4a55f92a --- /dev/null +++ b/Projects/Service/PickeAuth/Tests/Sources/PickeAuthTests.swift @@ -0,0 +1,57 @@ +// +// PickeAuthTests.swift +// PickeAuthTests +// + +import Foundation +import Testing + +@testable import PickeAuth +import PickeAuthTesting + +struct JWTDecoderTests { + /// `exp` 만 담은 payload 를 패딩 없는 base64url 로 만들어 실제 토큰 형태를 흉내낸다. + private func makeToken(expiration: TimeInterval) -> String { + let payload = try! JSONSerialization.data(withJSONObject: ["exp": expiration]) + let encoded = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encoded).signature" + } + + @Test + func decodesExpirationFromBase64URLPayload() { + let expiration: TimeInterval = 1_800_000_000 + + let date = JWTDecoder.decodeExpiration(makeToken(expiration: expiration)) + + #expect(date == Date(timeIntervalSince1970: expiration)) + } + + @Test + func returnsNilWhenSegmentsAreMissing() { + #expect(JWTDecoder.decodeExpiration("not-a-jwt") == nil) + } + + @Test + func returnsNilWhenPayloadHasNoExpiration() { + let payload = try! JSONSerialization.data(withJSONObject: ["sub": "1"]) + let encoded = payload.base64EncodedString() + + #expect(JWTDecoder.decodeExpiration("header.\(encoded).signature") == nil) + } +} + +struct StubAuthServiceTests { + @Test + func signOutClearsStoredTokens() async { + let service = StubAuthService(accessToken: "a", refreshToken: "r") + #expect(await service.isLoggedIn) + + await service.signOut() + + #expect(await service.isLoggedIn == false) + #expect(await service.refreshToken == nil) + } +} diff --git a/Projects/Service/PickeAuth/Tests/Support/AuthTestDoubles.swift b/Projects/Service/PickeAuth/Tests/Support/AuthTestDoubles.swift new file mode 100644 index 00000000..7f3c850e --- /dev/null +++ b/Projects/Service/PickeAuth/Tests/Support/AuthTestDoubles.swift @@ -0,0 +1,33 @@ +// +// AuthTestDoubles.swift +// PickeAuthTests +// + +import Foundation + +import PickeStorageInterface + +/// Keychain 대신 메모리 딕셔너리로 동작하는 보안 저장소 — 테스트 간 상태가 남지 않는다. +final class FakeSecureStorage: SecureStorage, @unchecked Sendable { + var values: [SecureStorageKey: String] + + init(values: [SecureStorageKey: String] = [:]) { + self.values = values + } + + func save(_ value: String, for key: SecureStorageKey) throws(SecureStorageError) { + values[key] = value + } + + func load(_ key: SecureStorageKey) throws(SecureStorageError) -> String? { + values[key] + } + + func remove(_ key: SecureStorageKey) throws(SecureStorageError) { + values[key] = nil + } + + func removeAll() throws(SecureStorageError) { + values.removeAll() + } +} diff --git a/Projects/Service/PickeConfig/Project.swift b/Projects/Service/PickeConfig/Project.swift new file mode 100644 index 00000000..eaa25d71 --- /dev/null +++ b/Projects/Service/PickeConfig/Project.swift @@ -0,0 +1,18 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeConfig", + bundleId: .appBundleID(name: ".PickeConfig"), + product: .framework, + settings: .settings(), + // 외부 SDK 부팅만 안다. 심볼을 재수출하지 않아 의존하지 않은 모듈로 새지 않는다. + dependencies: [ + .SPM.firebaseCrashlytics, + ] +) diff --git a/Projects/Service/PickeConfig/Sources/FirebaseConfiguration.swift b/Projects/Service/PickeConfig/Sources/FirebaseConfiguration.swift new file mode 100644 index 00000000..69454dd7 --- /dev/null +++ b/Projects/Service/PickeConfig/Sources/FirebaseConfiguration.swift @@ -0,0 +1,13 @@ +// +// FirebaseConfiguration.swift +// PickeAnalytics +// + +import Firebase + +/// Firebase 기동. 앱은 이 타입만 호출하고 Firebase 심볼은 여기 밖으로 새어 나가지 않는다. +public enum FirebaseConfiguration { + public static func configure() { + FirebaseApp.configure() + } +} diff --git a/Projects/Service/ServiceAssembly/Project.swift b/Projects/Service/ServiceAssembly/Project.swift new file mode 100644 index 00000000..80dcf213 --- /dev/null +++ b/Projects/Service/ServiceAssembly/Project.swift @@ -0,0 +1,26 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "ServiceAssembly", + bundleId: .appBundleID(name: ".ServiceAssembly"), + product: .framework, + settings: .settings(), + dependencies: [ + .coreAssembly, + .service(.api), + .service(.apiEndpoint), + .service(.analytics), + .service(.config), + .service(.audioPlayer), + .service(.device), + .service(.auth), + .service(.auth, .interface), + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Service/ServiceAssembly/Sources/DependencyValues+Network.swift b/Projects/Service/ServiceAssembly/Sources/DependencyValues+Network.swift new file mode 100644 index 00000000..97e4673f --- /dev/null +++ b/Projects/Service/ServiceAssembly/Sources/DependencyValues+Network.swift @@ -0,0 +1,26 @@ +// +// DependencyValues+Network.swift +// ServiceAssembly +// +// 네트워크·인증의 live 구현을 DependencyKey 에 등록한다. +// +// 두 값 모두 Keychain 과 인증 세션을 함께 다루는 NetworkContainer 가 만든다. +// Interface 쪽에는 계약과 테스트값만 두고, 실제 조립은 이 계층이 맡는다. +// + +import PickeAuthInterface +import PickeNetworkInterface + +import Dependencies + +extension NetworkClientDependency: DependencyKey { + public static var liveValue: any PickeNetworkClient { + NetworkContainer.authenticatedClient + } +} + +extension AuthServiceDependency: DependencyKey { + public static var liveValue: any AuthService { + NetworkContainer.authService + } +} diff --git a/Projects/Service/ServiceAssembly/Sources/Exported/ServiceAssemblyExported.swift b/Projects/Service/ServiceAssembly/Sources/Exported/ServiceAssemblyExported.swift new file mode 100644 index 00000000..f35299a6 --- /dev/null +++ b/Projects/Service/ServiceAssembly/Sources/Exported/ServiceAssemblyExported.swift @@ -0,0 +1,14 @@ +// +// ServiceAssemblyExported.swift +// ServiceAssembly +// + +// MARK: - Service 레이어 한번에 노출 + +@_exported import PickeAnalytics +@_exported import PickeConfig +@_exported import API +@_exported import AudioPlayerService +@_exported import CoreAssembly +@_exported import DeviceService +@_exported import PickeAuth diff --git a/Projects/Service/ServiceAssembly/Sources/NetworkContainer.swift b/Projects/Service/ServiceAssembly/Sources/NetworkContainer.swift new file mode 100644 index 00000000..16b1f5e6 --- /dev/null +++ b/Projects/Service/ServiceAssembly/Sources/NetworkContainer.swift @@ -0,0 +1,42 @@ +// +// NetworkContainer.swift +// ServiceAssembly +// + +import CoreAssembly +import PickeAuth +import PickeAuthInterface +import PickeNetworkInterface + +public enum NetworkContainer { + private static let assembly = makeAssembly() + + public static var authenticatedClient: any PickeNetworkClient { + assembly.authenticatedClient + } + + public static var authService: any AuthService { + assembly.authService + } + + private static func makeAssembly() -> Assembly { + let plainClient = NetworkAssembly.plainClient() + let storage = StorageAssembly.secureStorage() + let auth = AuthFactory.make( + refreshClient: plainClient, + storage: storage + ) + + return Assembly( + authenticatedClient: auth.authenticatedClient, + authService: auth + ) + } +} + +private extension NetworkContainer { + struct Assembly { + let authenticatedClient: any PickeNetworkClient + let authService: any AuthService + } +} diff --git a/Projects/Service/ServiceAssembly/Sources/ServiceDependencyAssembly.swift b/Projects/Service/ServiceAssembly/Sources/ServiceDependencyAssembly.swift new file mode 100644 index 00000000..a92e7d62 --- /dev/null +++ b/Projects/Service/ServiceAssembly/Sources/ServiceDependencyAssembly.swift @@ -0,0 +1,25 @@ +// +// ServiceDependencyAssembly.swift +// ServiceAssembly +// + +import CoreAssembly +import PickeAuthInterface +import PickeNetworkInterface +import PickeStorageInterface + +import Dependencies + +public enum ServiceDependencyAssembly { + public static func register(into values: inout DependencyValues) { + values.registerLiveServices() + } +} + +public extension DependencyValues { + mutating func registerLiveServices() { + StorageAssembly.register(into: &self) + networkClient = NetworkContainer.authenticatedClient + authService = NetworkContainer.authService + } +} diff --git a/Projects/Service/ServiceAssembly/Tests/Sources/ServiceDependencyAssemblyTests.swift b/Projects/Service/ServiceAssembly/Tests/Sources/ServiceDependencyAssemblyTests.swift new file mode 100644 index 00000000..2ad94cec --- /dev/null +++ b/Projects/Service/ServiceAssembly/Tests/Sources/ServiceDependencyAssemblyTests.swift @@ -0,0 +1,45 @@ +// +// ServiceDependencyAssemblyTests.swift +// ServiceAssemblyTests +// + +import Foundation +import Testing + +@testable import ServiceAssembly + +import CoreAssembly +import PickeAuthInterface +import PickeNetworkInterface +import PickeStorageInterface + +import Dependencies + +struct ServiceDependencyAssemblyTests { + /// 조립을 매번 새로 만들면 인증 세션이 갈라져 한쪽만 토큰을 갱신한다. + @Test + func 네트워크_컨테이너는_같은_인스턴스를_계속_내준다() { + #expect(NetworkContainer.authenticatedClient as AnyObject === NetworkContainer.authenticatedClient as AnyObject) + #expect(NetworkContainer.authService as AnyObject === NetworkContainer.authService as AnyObject) + } + + /// 앱 부팅 시 이 한 번의 등록으로 네트워크·인증이 모두 live 로 바뀌어야 한다. + @Test + func register_는_네트워크와_인증을_한번에_등록한다() throws { + var values = DependencyValues() + ServiceDependencyAssembly.register(into: &values) + + #expect(values.networkClient as AnyObject === NetworkContainer.authenticatedClient as AnyObject) + #expect(values.authService as AnyObject === NetworkContainer.authService as AnyObject) + // 저장소 조립도 함께 타야 공유 값이 앱 재실행 후에도 남는다. + let key = "ServiceDependencyAssemblyTests-\(UUID().uuidString)" + try values.sharedValueStorage.save(Data("값".utf8), forKey: key) + defer { try? values.sharedValueStorage.remove(forKey: key) } + } + + @Test + func 인증_클라이언트가_liveValue_로_노출된다() { + #expect(NetworkClientDependency.liveValue as AnyObject === NetworkContainer.authenticatedClient as AnyObject) + #expect(AuthServiceDependency.liveValue as AnyObject === NetworkContainer.authService as AnyObject) + } +} diff --git a/Projects/Shared/AdKit/Project.swift b/Projects/Shared/AdKit/Project.swift deleted file mode 100644 index 194bfdf6..00000000 --- a/Projects/Shared/AdKit/Project.swift +++ /dev/null @@ -1,21 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "AdKit"), - bundleId: .appBundleID(name: ".AdKit"), - // AdFitSDK.xcframework 가 DYLIB 이라 정적으로 감싸면 링크 경로가 꼬인다 — 동적 프레임워크로 둔다. - product: .framework, - settings: .settings(), - dependencies: [ - // AdFit 배너 뷰(AdFitBannerView)가 쓰는 SDK. 광고를 노출하는 화면만 이 모듈을 의존하므로 - // 디자인 시스템(PickeDesignKit)이나 스토리북 데모가 광고 SDK를 끌고 오지 않는다. - // AdFitSDK.xcframework 는 DYLIB 이라 여기 한 곳에서만 링크해도 앱에 단일 사본으로 임베드된다. - .SPM.adFit, - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Shared/PickeDesignKit/DesignSystemTests/Sources/Test.swift b/Projects/Shared/PickeDesignKit/DesignSystemTests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Shared/PickeDesignKit/DesignSystemTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Shared/PickeDesignKit/Project.swift b/Projects/Shared/PickeDesignKit/Project.swift deleted file mode 100644 index 17602e0f..00000000 --- a/Projects/Shared/PickeDesignKit/Project.swift +++ /dev/null @@ -1,25 +0,0 @@ -import DependencyPackagePlugin -import DependencyPlugin -import Foundation -import ProjectDescription -import ProjectTemplatePlugin - -let project = Project.configure( - moduleType: .module(name: "PickeDesignKit"), - bundleId: .appBundleID(name: ".PickeDesignKit"), - // 동적 프레임워크: 정적이면 리소스 번들 접근자(TuistBundle+PickeDesignKit)의 - // BundleFinder 가 앱 메인 바이너리로 병합되는데 번들은 Shared.framework 안에만 - // 복사되어 Bundle(for:) 이 앱 루트로 해석 → "unable to find bundle" 크래시가 났다. - // 동적이면 BundleFinder·번들이 PickeDesignKit.framework 에만 존재해 정확히 찾는다. - // TCA 중복 클래스는 Tuist/Package.swift 에서 ComposableArchitecture/Dependencies/ - // Perception/Sharing/IssueReporting 를 .framework(동적)로 전환해 단일 사본으로 해소한다. - product: .framework, - settings: .settings(), - dependencies: [ - .SPM.composableArchitecture, - ], - sources: ["Sources/**"], - resources: ["Resources/**"], - hasTests: false, - demoDisplayName: "Picke 스토리북" -) diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/ScreenSize/UIScreen+.swift b/Projects/Shared/PickeDesignKit/Sources/Extension/ScreenSize/UIScreen+.swift deleted file mode 100644 index cfa624bb..00000000 --- a/Projects/Shared/PickeDesignKit/Sources/Extension/ScreenSize/UIScreen+.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// UIScreen+.swift -// DDDAttendance -// -// Created by 서원지 on 6/6/24. -// - -import SwiftUI - -public extension UIScreen { - static let screenWidth = UIScreen.main.bounds.size.width - static let screenHeight = UIScreen.main.bounds.size.height - static let screenSize = UIScreen.main.bounds.size -} diff --git a/Projects/Shared/PickeDesignKit/Sources/Token/Typography/PretendardFontFamily.swift b/Projects/Shared/PickeDesignKit/Sources/Token/Typography/PretendardFontFamily.swift deleted file mode 100644 index dfbc7367..00000000 --- a/Projects/Shared/PickeDesignKit/Sources/Token/Typography/PretendardFontFamily.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// PretendardFontFamily.swift -// DesignSystem -// -// Created by 서원지 on 7/13/24. -// - -import Foundation - -public enum PretendardFontFamily { - case Black - case Bold - case ExtraBold - case ExtraLight - case Light - case Medium - case Regular - case SemiBold - case Thin -} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift deleted file mode 100644 index fe9897d1..00000000 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ /dev/null @@ -1,569 +0,0 @@ -// -// CustomConfirmationPopupView.swift -// DesignSystem -// - -import SwiftUI - -struct CustomConfirmationPopup: View { - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - - private let title: String - private let message: String - private let confirmTitle: String - private let cancelTitle: String - private let isDestructive: Bool - private let style: CustomAlertStyle - private let onConfirm: () -> Void - private let onCancel: () -> Void - - @State private var isContentVisible = false - - init( - title: String, - message: String, - confirmTitle: String, - cancelTitle: String, - isDestructive: Bool, - style: CustomAlertStyle, - onConfirm: @escaping () -> Void, - onCancel: @escaping () -> Void - ) { - self.title = title - self.message = message - self.confirmTitle = confirmTitle - self.cancelTitle = cancelTitle - self.isDestructive = isDestructive - self.style = style - self.onConfirm = onConfirm - self.onCancel = onCancel - } - - var body: some View { - GeometryReader { proxy in - ZStack { - Color.black - .opacity(isContentVisible ? 0.6 : 0) - .ignoresSafeArea() - .onTapGesture(perform: onCancel) - - popupContent - .frame(maxWidth: popupMaxWidth(for: proxy.size.width)) - .padding(.horizontal, popupHorizontalPadding) - .offset(y: isContentVisible ? 0 : 120) - .opacity(isContentVisible ? 1 : 0) - .accessibilityAddTraits(.isModal) - } - } - .onAppear { - withAnimation(.easeInOut(duration: 0.3)) { - isContentVisible = true - } - } - } - - private func popupMaxWidth(for containerWidth: CGFloat) -> CGFloat { - max(0, min(containerWidth - popupHorizontalPadding * 2, 360)) - } - - private var popupHorizontalPadding: CGFloat { - switch style { - case .confirmation: - return 20 - case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic: - return 0 - } - } - - @ViewBuilder - private var popupContent: some View { - switch style { - case .confirmation: - confirmationContent - case .finalVote: - finalVoteContent - case .report: - reportContent - case .alreadyWatched: - alreadyWatchedContent - case .deleteConfirm: - deleteConfirmContent - case .logout, .withdraw: - logoutWithdrawContent - case .suggestTopic: - suggestTopicContent - } - } - - /// 로그아웃/탈퇴 — 공통 팝업 스타일(무료 충전/다시 시청 팝업과 동일). - /// 본문 + 라운드 2버튼(확정=왼쪽 밝은 secondary50 / 취소=오른쪽 primary500). - private var logoutWithdrawContent: some View { - pickeAlertCard { - VStack(spacing: 16) { - pickeBodyText(title) - - pickeTwoButtonRow( - leftTitle: confirmTitle, leftAction: onConfirm, - rightTitle: cancelTitle, rightAction: onCancel - ) - } - } - } - - /// 주제 제안 — 타이틀 + 본문 + 뒤로가기(왼쪽 밝은) / 제안하기(오른쪽 primary). - private var suggestTopicContent: some View { - pickeAlertCard { - VStack(spacing: 16) { - VStack(spacing: 10) { - Text(title) - .pretendardFont(.headingMedium) - .foregroundStyle(.primary800) - .multilineTextAlignment(.center) - - if !message.isEmpty { - pickeBodyText(message) - } - } - - pickeTwoButtonRow( - leftTitle: cancelTitle, leftAction: onCancel, - rightTitle: confirmTitle, rightAction: onConfirm - ) - } - } - } - - // MARK: picke 공통 팝업 헬퍼 - - /// 베이지 카드 + primary 보더 컨테이너 (width 313, top padding 20). - private func pickeAlertCard( - opacity: Double = 1, - @ViewBuilder content: () -> some View - ) -> some View { - content() - .padding(.top, 20) - .frame(maxWidth: 313) - .pickeCard( - .beige500, - border: .primary500, - lineWidth: 1.5 - ) - .opacity(opacity) - .clipShape(RoundedRectangle(cornerRadius: .radiusDefault)) - .onTapGesture {} - } - - /// 본문 텍스트 (14/Medium, primary800, 가운데, 좌우 20). - private func pickeBodyText(_ text: String) -> some View { - Text(text) - .pretendardFont(.labelMedium) - .foregroundStyle(.primary800) - .lineSpacing(14 * 0.4) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.horizontal, 20) - } - - /// 좌(밝은)·우(primary) 2버튼 행. 좌/우 의미는 호출부가 결정. - @ViewBuilder - private func pickeTwoButtonRow( - leftTitle: String, - leftAction: @escaping () -> Void, - rightTitle: String, - rightAction: @escaping () -> Void - ) -> some View { - if dynamicTypeSize.isAccessibilitySize { - VStack(spacing: 8) { - pickeSecondaryButton(title: leftTitle, action: leftAction) - pickePrimaryButton(title: rightTitle, action: rightAction) - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - } else { - HStack(spacing: 8) { - pickeSecondaryButton(title: leftTitle, action: leftAction) - pickePrimaryButton(title: rightTitle, action: rightAction) - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - } - - @ViewBuilder - private func pickeSecondaryButton( - title: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Text(title) - .pretendardFont(.labelMedium) - .foregroundStyle(.primary800) - .lineLimit(1) - .minimumScaleFactor(0.85) - .frame(maxWidth: .infinity) - .frame(minHeight: 48) - .roundedBackground(.secondary50, radius: 8) - } - .buttonStyle(.plain) - } - - @ViewBuilder - private func pickePrimaryButton( - title: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Text(title) - .pretendardFont(.labelMedium) - .foregroundStyle(.secondary50) - .lineLimit(1) - .minimumScaleFactor(0.85) - .frame(maxWidth: .infinity) - .frame(minHeight: 48) - .roundedBackground(.primary500, radius: 8) - } - .buttonStyle(.plain) - } - - private var deleteConfirmContent: some View { - VStack(spacing: 16) { - Text(title) - .pretendardFont(.labelMedium) - .foregroundStyle(.neutral900) - .lineSpacing(14 * 0.4) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.horizontal, 20) - - HStack(spacing: 0) { - // 삭제하기 (왼쪽·밝은 버튼) = 확정(삭제) - Button(action: onConfirm) { - Text(confirmTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.primary500) - .lineSpacing(14 * 0.4) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.secondary50, in: Rectangle()) - } - .buttonStyle(.plain) - - // 뒤로가기 (오른쪽·어두운 버튼) = 취소 - Button(action: onCancel) { - Text(cancelTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.secondary50) - .lineSpacing(14 * 0.4) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.primary500, in: Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.top, 20) - .frame(maxWidth: 313) - .pickeCard( - .beige500, - border: .primary500, - lineWidth: 1.5 - ) - .opacity(0.9) - .clipShape(RoundedRectangle(cornerRadius: .radiusDefault)) - .onTapGesture {} - } - - @ViewBuilder - private var alreadyWatchedContent: some View { - VStack(spacing: 12) { - VStack(spacing: 8) { - Text(title) - .pretendardFont(.headingMedium) - .foregroundStyle(.primary800) - .kerning(-0.4) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - - if !message.isEmpty { - Text(message) - .pretendardFont(.medium13) - .foregroundStyle(.neutral400) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - } - } - - HStack(spacing: 0) { - Button(action: onCancel) { - Text(cancelTitle.isEmpty ? "취소" : cancelTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.primary500) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.secondary50, in: Rectangle()) - } - .buttonStyle(.plain) - - Button(action: onConfirm) { - Text(confirmTitle.isEmpty ? "다시" : confirmTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.secondary50) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.primary500, in: Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.top, 24) - .frame(maxWidth: 343) - .pickeCard( - .beige500, - border: .primary500, - lineWidth: 1.5, - radius: 6 - ) - .onTapGesture {} - } - - private var confirmationContent: some View { - VStack(spacing: 24) { - VStack(spacing: 8) { - Text(title) - .pretendardFont(.bold18) - .foregroundStyle(.neutral800) - .multilineTextAlignment(.center) - - if !message.isEmpty { - Text(message) - .pretendardFont(.regular13) - .foregroundStyle(.neutral400) - .lineSpacing(13 * 0.4) - .multilineTextAlignment(.center) - } - } - - HStack(spacing: 8) { - if !cancelTitle.isEmpty { - Button(action: onCancel) { - Text(cancelTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.neutral500) - .frame(maxWidth: .infinity) - .frame(height: 48) - .roundedBackground(.beige600) - } - .buttonStyle(.plain) - } - - Button(action: onConfirm) { - Text(confirmTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.beige50) - .frame(maxWidth: .infinity) - .frame(height: 48) - .background( - isDestructive ? Color.errorDefault : Color.primary500, - in: RoundedRectangle(cornerRadius: .radiusDefault) - ) - } - .buttonStyle(.plain) - } - } - .padding(.vertical, 28) - .padding(.horizontal, 20) - .frame(maxWidth: 320) - .pickeCard(ComponentToken.Popup.background, border: ComponentToken.Popup.border) - .onTapGesture {} - } - - private var finalVoteContent: some View { - VStack(spacing: 16) { - Text(title) - .pretendardFont(.labelMedium) - .foregroundStyle(.neutral900) - .lineSpacing(14 * 0.4) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.horizontal, 20) - - HStack(spacing: 0) { - Button(action: onCancel) { - Text(cancelTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.primary500) - .lineSpacing(14 * 0.4) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.secondary50, in: Rectangle()) - } - .buttonStyle(.plain) - - Button(action: onConfirm) { - Text(confirmTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.secondary50) - .lineSpacing(14 * 0.4) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.primary500, in: Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.top, 20) - .frame(maxWidth: 313) - .pickeCard( - .beige500, - border: .primary500, - lineWidth: 1.5 - ) - .opacity(0.9) - .onTapGesture {} - } - - @State private var selectedReason: ReportReason? - - @ViewBuilder - private var reportContent: some View { - VStack(spacing: 16) { - reportHeader - reasonGrid - reportButtons - } - .padding(.top, 20) - .frame(maxWidth: 343) - .roundedBackground(.beige500, radius: 6) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .roundedBorder( - .primary500, - lineWidth: 1.5, - radius: 6 - ) - .onTapGesture {} - } - - @ViewBuilder - private var reportHeader: some View { - Text("신고사유") - .pretendardFont(.headingMedium) - .foregroundStyle(.primary800) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - } - - @ViewBuilder - private var reasonGrid: some View { - HStack(alignment: .top, spacing: 0) { - reasonColumn(ReportReason.leftColumn) - reasonColumn(ReportReason.rightColumn, fillSpacer: true) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .frame(height: 116) - } - - @ViewBuilder - private func reasonColumn( - _ reasons: [ReportReason], - fillSpacer: Bool = false - ) -> some View { - VStack(alignment: .leading, spacing: 16) { - ForEach(reasons) { reason in - reasonRow(reason) - } - if fillSpacer { - Spacer(minLength: 0) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private func reasonRow(_ reason: ReportReason) -> some View { - Button { - selectedReason = reason - } label: { - HStack(spacing: 6) { - reasonRadio(isSelected: selectedReason == reason) - Text(reason.title) - .pretendardFont(.labelMedium) - .foregroundStyle(.neutral900) - } - } - .buttonStyle(.plain) - } - - @ViewBuilder - private func reasonRadio(isSelected: Bool) -> some View { - ZStack { - Circle() - .stroke(.gray200, lineWidth: 3) - .frame(width: 20, height: 20) - if isSelected { - Circle() - .fill(.primary500) - .frame(width: 10, height: 10) - } - } - } - - @ViewBuilder - private var reportButtons: some View { - HStack(spacing: 10) { - reportSubmitButton - reportCancelButton - } - } - - @ViewBuilder - private var reportSubmitButton: some View { - Button(action: onConfirm) { - Text(confirmTitle.isEmpty ? "신고하기" : confirmTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.primary800) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.secondary50, in: Rectangle()) - } - .buttonStyle(.plain) - .disabled(selectedReason == nil) - .opacity(selectedReason == nil ? 0.5 : 1) - } - - @ViewBuilder - private var reportCancelButton: some View { - Button(action: onCancel) { - Text(cancelTitle.isEmpty ? "뒤로가기" : cancelTitle) - .pretendardFont(.labelMedium) - .foregroundStyle(.secondary50) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.primary500, in: Rectangle()) - } - .buttonStyle(.plain) - } -} - -public enum ReportReason: String, CaseIterable, Identifiable, Equatable { - case commercial - case repeat_ - case explicit - case insult - case other - - public var id: String { rawValue } - - public var title: String { - switch self { - case .commercial: "영리목적/홍보성" - case .repeat_: "같은 내용 반복 게시" - case .explicit: "음란성/선정성" - case .insult: "욕설/인신공격" - case .other: "기타" - } - } - - static let leftColumn: [ReportReason] = [.commercial, .explicit, .other] - static let rightColumn: [ReportReason] = [.repeat_, .insult] -} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Share/ShareSheet.swift b/Projects/Shared/PickeDesignKit/Sources/UI/Share/ShareSheet.swift deleted file mode 100644 index 7b4513fe..00000000 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Share/ShareSheet.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ShareSheet.swift -// DesignSystem -// - -import SwiftUI -import UIKit - -public struct ShareSheet: UIViewControllerRepresentable { - private let items: [Any] - - public init(items: [Any]) { - self.items = items - } - - public func makeUIViewController(context _: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) - } - - public func updateUIViewController( - _: UIActivityViewController, - context _: Context - ) {} -} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Skeleton/SkeletonBlock.swift b/Projects/Shared/PickeDesignKit/Sources/UI/Skeleton/SkeletonBlock.swift deleted file mode 100644 index 0c45f0be..00000000 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Skeleton/SkeletonBlock.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// SkeletonBlock.swift -// DesignSystem -// - -import SwiftUI - -public struct SkeletonBlock: View { - /// 배경 톤 — 라이트(beige) / 다크 화면에 맞춰 base·shimmer 색을 고른다. - public enum Tone { - case light - case dark - - var base: Color { - switch self { - case .light: Color.black.opacity(0.06) - case .dark: Color.white.opacity(0.08) - } - } - - var shimmer: Color { - switch self { - case .light: Color.white.opacity(0.55) - case .dark: Color.white.opacity(0.20) - } - } - } - - private let cornerRadius: CGFloat - private let tone: Tone - - @State private var phase: CGFloat = -1 - - public init( - cornerRadius: CGFloat = 4, - tone: Tone = .light - ) { - self.cornerRadius = cornerRadius - self.tone = tone - } - - public var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(tone.base) - .overlay { - LinearGradient( - stops: [ - .init(color: tone.shimmer.opacity(0), location: 0), - .init(color: tone.shimmer, location: 0.5), - .init(color: tone.shimmer.opacity(0), location: 1), - ], - startPoint: UnitPoint(x: phase, y: 0.5), - endPoint: UnitPoint(x: phase + 1, y: 0.5) - ) - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { - phase = 2 - } - } - } -} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift b/Projects/Shared/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift deleted file mode 100644 index 6e6a9285..00000000 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SkeletonView.swift -// DesignSystem -// - -import SwiftUI - -public struct SkeletonView: View { - private let cornerRadius: CGFloat - private let baseColor: Color - private let shimmerColor: Color - - public init( - cornerRadius: CGFloat = 2, - baseColor: Color = .beige600, - shimmerColor: Color = .beige50 - ) { - self.cornerRadius = cornerRadius - self.baseColor = baseColor - self.shimmerColor = shimmerColor - } - - @State private var phase: CGFloat = -1 - - public var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(baseColor) - .overlay { - LinearGradient( - stops: [ - .init(color: baseColor.opacity(0), location: 0), - .init(color: shimmerColor.opacity(0.6), location: 0.5), - .init(color: baseColor.opacity(0), location: 1), - ], - startPoint: UnitPoint(x: phase, y: 0.5), - endPoint: UnitPoint(x: phase + 1, y: 0.5) - ) - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .onAppear { - withAnimation( - .linear(duration: 1.2).repeatForever(autoreverses: false) - ) { - phase = 2 - } - } - } -} diff --git a/Projects/Shared/Shared/Project.swift b/Projects/Shared/Shared/Project.swift deleted file mode 100644 index 877208c6..00000000 --- a/Projects/Shared/Shared/Project.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.configure( - moduleType: .module(name: "Shared"), - bundleId: .appBundleID(name: ".Shared"), - product: .framework, - settings: .settings(), - dependencies: [ - .Shared(implements: .PickeDesignKit), - .Shared(implements: .Utill), - .Shared(implements: .ThirdParty) - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Shared/Shared/SharedTests/Sources/Test.swift b/Projects/Shared/Shared/SharedTests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Shared/Shared/SharedTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Shared/Shared/Sources/Base.swift b/Projects/Shared/Shared/Sources/Base.swift deleted file mode 100644 index 6297cc4c..00000000 --- a/Projects/Shared/Shared/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/Shared/ThirdParty/Sources/Base.swift b/Projects/Shared/ThirdParty/Sources/Base.swift deleted file mode 100644 index 1b586673..00000000 --- a/Projects/Shared/ThirdParty/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2026-05-07 -// Copyright © 2026 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/Shared/ThirdParty/Tests/Sources/ThirdPartyTests.swift b/Projects/Shared/ThirdParty/Tests/Sources/ThirdPartyTests.swift deleted file mode 100644 index 549dc5c1..00000000 --- a/Projects/Shared/ThirdParty/Tests/Sources/ThirdPartyTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ThirdPartyTests.swift -// Shared.ThirdPartyTests -// -// Created by Roy on 2026-05-07. -// - -import Testing -@testable import ThirdParty - -struct ThirdPartyTests { - - @Test - func thirdpartyExample() { - // This is an example of a test case. - #expect(true) - } - - @Test - func thirdpartyLogicTest() { - // Add your test logic here. - let result = true - #expect(result == true) - } - -} - diff --git a/Projects/Shared/Utill/Project.swift b/Projects/Shared/Utill/Project.swift deleted file mode 100644 index 0035bd62..00000000 --- a/Projects/Shared/Utill/Project.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.configure( - moduleType: .module(name: "Utill"), - bundleId: .appBundleID(name: ".Utill"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - - ], - sources: ["Sources/**"], - hasTests: false -) diff --git a/Projects/Shared/Utill/UtillTests/Sources/Test.swift b/Projects/Shared/Utill/UtillTests/Sources/Test.swift deleted file mode 100644 index a9c810e3..00000000 --- a/Projects/Shared/Utill/UtillTests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/UI/PickeAnimation/Project.swift b/Projects/UI/PickeAnimation/Project.swift new file mode 100644 index 00000000..aea3f8e5 --- /dev/null +++ b/Projects/UI/PickeAnimation/Project.swift @@ -0,0 +1,21 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +// 애니메이션 라이브러리를 쓰는 뷰와 그 에셋만 둔다. +// SDWebImage 가 여기서 막히므로 DesignKit 과 화면 코드는 이 라이브러리를 모른다. +let project = Project.makeModule( + name: "PickeAnimation", + bundleId: .appBundleID(name: ".PickeAnimation"), + product: .framework, + settings: .settings(), + dependencies: [ + .SPM.sdwebImageCore, + ], + resources: ["Resources/**"], + hasTests: true +) \ No newline at end of file diff --git a/Projects/App/Resources/splashLogo.gif b/Projects/UI/PickeAnimation/Resources/splashLogo.gif similarity index 100% rename from Projects/App/Resources/splashLogo.gif rename to Projects/UI/PickeAnimation/Resources/splashLogo.gif diff --git a/Projects/UI/PickeAnimation/Sources/PickeAnimatedImageView.swift b/Projects/UI/PickeAnimation/Sources/PickeAnimatedImageView.swift new file mode 100644 index 00000000..c0929f7d --- /dev/null +++ b/Projects/UI/PickeAnimation/Sources/PickeAnimatedImageView.swift @@ -0,0 +1,50 @@ +// +// PickeAnimatedImageView.swift +// PickeAnimation +// + +import SDWebImage +import SwiftUI + +/// GIF 애니메이션을 재생하는 공용 뷰. +/// SDWebImage 를 이 모듈 안에 가둬 화면 코드가 라이브러리를 모르게 한다. +public struct PickeAnimatedImageView: UIViewRepresentable { + private let asset: PickeAnimationAsset + private let size: CGSize + + public init( + _ asset: PickeAnimationAsset, + size: CGSize + ) { + self.asset = asset + self.size = size + } + + public func makeUIView(context _: Context) -> SDAnimatedImageView { + let imageView = SDAnimatedImageView() + imageView.image = asset.image + imageView.contentMode = .scaleAspectFit + imageView.maxBufferSize = .max + imageView.shouldIncrementalLoad = false + imageView.autoPlayAnimatedImage = true + imageView.startAnimating() + return imageView + } + + public func updateUIView( + _ imageView: SDAnimatedImageView, + context _: Context + ) { + guard imageView.image !== asset.image else { return } + imageView.image = asset.image + imageView.startAnimating() + } + + public func sizeThatFits( + _: ProposedViewSize, + uiView _: SDAnimatedImageView, + context _: Context + ) -> CGSize? { + size + } +} diff --git a/Projects/UI/PickeAnimation/Sources/PickeAnimationAsset.swift b/Projects/UI/PickeAnimation/Sources/PickeAnimationAsset.swift new file mode 100644 index 00000000..bc801b61 --- /dev/null +++ b/Projects/UI/PickeAnimation/Sources/PickeAnimationAsset.swift @@ -0,0 +1,33 @@ +// +// PickeAnimationAsset.swift +// PickeAnimation +// + +import SDWebImage + +/// 이 모듈이 번들로 갖고 있는 애니메이션 에셋. +/// 파일명을 화면 코드에 흘리지 않기 위해 여기서만 이름을 안다. +public enum PickeAnimationAsset: String, CaseIterable { + case splashLogo = "splashLogo.gif" + + /// GIF 디코딩은 비용이 있어 에셋당 한 번만 만든다. + var image: SDAnimatedImage? { + Self.images[self] ?? nil + } + + private static let images: [PickeAnimationAsset: SDAnimatedImage?] = { + var loaded: [PickeAnimationAsset: SDAnimatedImage?] = [:] + for asset in allCases { + loaded[asset] = SDAnimatedImage(named: asset.rawValue, in: .pickeAnimation, compatibleWith: nil) + } + return loaded + }() +} + +private final class BundleToken {} + +private extension Bundle { + /// 이 모듈은 동적 프레임워크라 에셋이 자기 번들에 들어 있다. + /// Tuist 가 리소스 접근자(`Bundle.module`)를 만들어주지 않아 직접 잡는다. + static let pickeAnimation = Bundle(for: BundleToken.self) +} diff --git a/Projects/UI/PickeAnimation/Tests/Sources/PickeAnimationAssetTests.swift b/Projects/UI/PickeAnimation/Tests/Sources/PickeAnimationAssetTests.swift new file mode 100644 index 00000000..f23d3949 --- /dev/null +++ b/Projects/UI/PickeAnimation/Tests/Sources/PickeAnimationAssetTests.swift @@ -0,0 +1,30 @@ +// +// PickeAnimationAssetTests.swift +// PickeAnimationTests +// + +import Testing + +@testable import PickeAnimation + +struct PickeAnimationAssetTests { + /// 에셋 파일이 빠지거나 이름이 바뀌면 스플래시가 빈 화면으로 뜬다. + /// 번들 로딩까지 확인해야 그 회귀를 여기서 잡는다. + @Test + func 모든_에셋이_번들에서_로드된다() { + for asset in PickeAnimationAsset.allCases { + #expect(asset.image != nil, "\(asset.rawValue) 를 번들에서 찾지 못했다") + } + } + + /// GIF 디코딩 비용 때문에 에셋당 한 번만 만들기로 했다. + @Test + func 같은_에셋은_디코딩된_이미지를_재사용한다() { + #expect(PickeAnimationAsset.splashLogo.image === PickeAnimationAsset.splashLogo.image) + } + + @Test + func 에셋_파일명은_확장자를_포함한다() { + #expect(PickeAnimationAsset.splashLogo.rawValue == "splashLogo.gif") + } +} diff --git a/Projects/Shared/PickeDesignKit/Demo/Sources/PickeDesignKitDemoApp.swift b/Projects/UI/PickeDesignKit/Demo/Sources/PickeDesignKitDemoApp.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Demo/Sources/PickeDesignKitDemoApp.swift rename to Projects/UI/PickeDesignKit/Demo/Sources/PickeDesignKitDemoApp.swift diff --git a/Projects/Shared/PickeDesignKit/Demo/Sources/StorybookView.swift b/Projects/UI/PickeDesignKit/Demo/Sources/StorybookView.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Demo/Sources/StorybookView.swift rename to Projects/UI/PickeDesignKit/Demo/Sources/StorybookView.swift diff --git a/Projects/UI/PickeDesignKit/Project.swift b/Projects/UI/PickeDesignKit/Project.swift new file mode 100644 index 00000000..2bf4bf0f --- /dev/null +++ b/Projects/UI/PickeDesignKit/Project.swift @@ -0,0 +1,22 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "PickeDesignKit", + bundleId: .appBundleID(name: ".PickeDesignKit"), + product: .framework, + settings: .settings(), + dependencies: [ + .core(.coreUI), + .SPM.composableArchitecture, + ], + resources: ["Resources/**"], + hasTests: true, + hasDemo: true, + demoDisplayName: "Picke 스토리북" +) \ No newline at end of file diff --git a/Projects/Shared/PickeDesignKit/Resources/FontAsset/PretendardVariable.ttf b/Projects/UI/PickeDesignKit/Resources/FontAsset/PretendardVariable.ttf similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/FontAsset/PretendardVariable.ttf rename to Projects/UI/PickeDesignKit/Resources/FontAsset/PretendardVariable.ttf diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/checkBlue.imageset/checkBlue.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Auth/errorXmark.imageset/errorXmark.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/heartPlus.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/heartPlus.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/heartPlus.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Comment/heartPlus.imageset/heartPlus.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/tabQuickBattleActive.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/tabQuickBattleActive.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/tabQuickBattleActive.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/tabQuickBattleActive.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json diff --git "a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" "b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" similarity index 100% rename from "Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" rename to "Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json diff --git "a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" "b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" similarity index 100% rename from "Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" rename to "Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json diff --git "a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" "b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" similarity index 100% rename from "Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" rename to "Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json diff --git "a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" "b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" similarity index 100% rename from "Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" rename to "Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json diff --git "a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" "b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" similarity index 100% rename from "Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" rename to "Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json diff --git "a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" "b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" similarity index 100% rename from "Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" rename to "Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/splashLogo.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/splashLogo.png similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/splashLogo.png rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/logo/splashLogo.imageset/splashLogo.png diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/google.imageset/google.svg diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/Contents.json diff --git a/Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg similarity index 100% rename from Projects/Shared/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg rename to Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/socialLogin/kakao.imageset/kakao.svg diff --git a/Projects/UI/PickeDesignKit/Sources/Exported/PickeDesignKitExported.swift b/Projects/UI/PickeDesignKit/Sources/Exported/PickeDesignKitExported.swift new file mode 100644 index 00000000..9e7c06fa --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/Exported/PickeDesignKitExported.swift @@ -0,0 +1,9 @@ +// +// PickeDesignKitExported.swift +// PickeDesignKit +// + +// 토큰을 모르는 순수 확장은 PickeCoreUI 로 내렸다. +// 화면 코드는 여전히 PickeDesignKit 하나만 import 하면 되도록 재노출한다. + +@_exported import PickeCoreUI diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Component+.swift b/Projects/UI/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Component+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Component+.swift rename to Projects/UI/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Component+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Radius+.swift b/Projects/UI/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Radius+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Radius+.swift rename to Projects/UI/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Radius+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Spacing+.swift b/Projects/UI/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Spacing+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Spacing+.swift rename to Projects/UI/PickeDesignKit/Sources/Extension/CGFloat/CGFloat+Spacing+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/Image/UIImage+.swift b/Projects/UI/PickeDesignKit/Sources/Extension/Image/UIImage+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/Image/UIImage+.swift rename to Projects/UI/PickeDesignKit/Sources/Extension/Image/UIImage+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/View/View+Background.swift b/Projects/UI/PickeDesignKit/Sources/Extension/View/View+Background.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/View/View+Background.swift rename to Projects/UI/PickeDesignKit/Sources/Extension/View/View+Background.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Extension/View/View+Border.swift b/Projects/UI/PickeDesignKit/Sources/Extension/View/View+Border.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Extension/View/View+Border.swift rename to Projects/UI/PickeDesignKit/Sources/Extension/View/View+Border.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Token/Color/ShapeStyle+.swift b/Projects/UI/PickeDesignKit/Sources/Token/Color/ShapeStyle+.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Token/Color/ShapeStyle+.swift rename to Projects/UI/PickeDesignKit/Sources/Token/Color/ShapeStyle+.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Token/Image/ImageAsset.swift b/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Token/Image/ImageAsset.swift rename to Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Token/Typography/CustomSize.swift b/Projects/UI/PickeDesignKit/Sources/Token/Typography/CustomSize.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/Token/Typography/CustomSize.swift rename to Projects/UI/PickeDesignKit/Sources/Token/Typography/CustomSize.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift similarity index 74% rename from Projects/Shared/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift rename to Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift index 58b8f2aa..5a77db45 100644 --- a/Projects/Shared/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift +++ b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift @@ -12,7 +12,13 @@ public struct PretendardFont: ViewModifier { public let size: CGFloat public func body(content: Content) -> some View { - return content.font(.custom("PretendardVariable-\(family)", fixedSize: size)) + content + .font( + .custom( + "PretendardVariable-\(family)", + fixedSize: size + ) + ) } } @@ -21,11 +27,21 @@ public extension View { family: PretendardFontFamily, size: CGFloat ) -> some View { - return modifier(PretendardFont(family: family, size: size)) + modifier( + PretendardFont( + family: family, + size: size + ) + ) } func pretendardFont(_ textStyle: CustomSizeFont) -> some View { - return modifier(PretendardFont(family: textStyle.fontFamily, size: textStyle.size)) + modifier( + PretendardFont( + family: textStyle.fontFamily, + size: textStyle.size + ) + ) } } diff --git a/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFontFamily.swift b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFontFamily.swift new file mode 100644 index 00000000..86fc0a2d --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFontFamily.swift @@ -0,0 +1,30 @@ +// +// PretendardFontFamily.swift +// DesignSystem +// +// Created by 서원지 on 7/13/24. +// + +import Foundation + +public enum PretendardFontFamily { + case Black + case Bold + case ExtraBold + case ExtraLight + case Light + case Medium + case Regular + case SemiBold + case Thin + + /// 번들에 든 Pretendard 를 런타임에 등록한다. 같은 파일을 여러 weight 가 공유하므로 경로로 한 번만 거른다. + public static func registerFonts() { + var registeredPaths = Set() + for font in PickeDesignKitFontFamily.allCustomFonts + where registeredPaths.insert(font.path).inserted + { + font.register() + } + } +} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/ActionSheet/BottomActionSheet.swift b/Projects/UI/PickeDesignKit/Sources/UI/ActionSheet/BottomActionSheet.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/ActionSheet/BottomActionSheet.swift rename to Projects/UI/PickeDesignKit/Sources/UI/ActionSheet/BottomActionSheet.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/ActionSheet/PickeActionPill.swift b/Projects/UI/PickeDesignKit/Sources/UI/ActionSheet/PickeActionPill.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/ActionSheet/PickeActionPill.swift rename to Projects/UI/PickeDesignKit/Sources/UI/ActionSheet/PickeActionPill.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Avatar/PickeAvatar.swift b/Projects/UI/PickeDesignKit/Sources/UI/Avatar/PickeAvatar.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Avatar/PickeAvatar.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Avatar/PickeAvatar.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift b/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Button/CTAButtonStyle.swift b/Projects/UI/PickeDesignKit/Sources/UI/Button/CTAButtonStyle.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Button/CTAButtonStyle.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Button/CTAButtonStyle.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Button/CTAButtonStyleModifier.swift b/Projects/UI/PickeDesignKit/Sources/UI/Button/CTAButtonStyleModifier.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Button/CTAButtonStyleModifier.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Button/CTAButtonStyleModifier.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Card/PickeListRowCard.swift b/Projects/UI/PickeDesignKit/Sources/UI/Card/PickeListRowCard.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Card/PickeListRowCard.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Card/PickeListRowCard.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Chip/PickeSortChip.swift b/Projects/UI/PickeDesignKit/Sources/UI/Chip/PickeSortChip.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Chip/PickeSortChip.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Chip/PickeSortChip.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Divider/PickeDivider.swift b/Projects/UI/PickeDesignKit/Sources/UI/Divider/PickeDivider.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Divider/PickeDivider.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Divider/PickeDivider.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Floating/FloatingErrorView.swift b/Projects/UI/PickeDesignKit/Sources/UI/Floating/FloatingErrorView.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Floating/FloatingErrorView.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Floating/FloatingErrorView.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Share/ShareSheet.swift b/Projects/UI/PickeDesignKit/Sources/UI/Share/ShareSheet.swift new file mode 100644 index 00000000..1f9f42d5 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Share/ShareSheet.swift @@ -0,0 +1,41 @@ +// +// ShareSheet.swift +// PickeDesignKit +// + +import SwiftUI +import UIKit + +/// 시스템 공유 시트. +/// +/// SwiftUIX 의 `AppActivityView` 에서 완료 콜백과 제외 액티비티 옵션만 옮겨 왔다. +/// 라이브러리 전체를 의존성으로 들이는 대신 필요한 조각만 가져온다. (SwiftUIX, MIT License) +public struct ShareSheet: UIViewControllerRepresentable { + private let items: [Any] + private let excludedActivityTypes: [UIActivity.ActivityType] + private let onComplete: ((Bool) -> Void)? + + public init( + items: [Any], + excludedActivityTypes: [UIActivity.ActivityType] = [], + onComplete: ((Bool) -> Void)? = nil + ) { + self.items = items + self.excludedActivityTypes = excludedActivityTypes + self.onComplete = onComplete + } + + public func makeUIViewController(context _: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + controller.excludedActivityTypes = excludedActivityTypes.isEmpty ? nil : excludedActivityTypes + controller.completionWithItemsHandler = { _, completed, _, _ in + onComplete?(completed) + } + return controller + } + + public func updateUIViewController( + _: UIActivityViewController, + context _: Context + ) {} +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/Skeleton+View.swift b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/Skeleton+View.swift new file mode 100644 index 00000000..e6438c86 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/Skeleton+View.swift @@ -0,0 +1,64 @@ +// +// Skeleton+View.swift +// PickeDesignKit +// + +import SwiftUI + +// MARK: - View Extension + +public extension View { + /// 데이터가 오기 전 원본이 비어 크기를 잃는 경우 `width` / `height` 로 자리를 빌려준다. + /// 로딩 중에만 적용되므로 데이터가 온 뒤의 동적 크기는 방해하지 않는다. + func skeleton( + isLoading: Bool, + shape: SkeletonShape, + width: CGFloat? = nil, + height: CGFloat? = nil, + base: Color = .beige600, + highlight: Color = .beige50 + ) -> some View { + modifier( + SkeletonModifier( + isLoading: isLoading, + width: width, + height: height, + shape: shape, + base: base, + highlight: highlight + ) + ) + } +} + +// MARK: - Modifier + +private struct SkeletonModifier: ViewModifier { + /// 자리표시자가 나타나고 사라질 때의 페이드 길이(초). + private static let fadeDuration: Double = 0.2 + + let isLoading: Bool + let width: CGFloat? + let height: CGFloat? + let shape: SkeletonShape + let base: Color + let highlight: Color + + func body(content: Content) -> some View { + ZStack { + content + .opacity(isLoading ? 0 : 1) + + if isLoading { + SkeletonView(shape, base: base, highlight: highlight) + .frame(width: width, height: height) + .transition(.opacity) + } + } + .fixedSize( + horizontal: isLoading && width != nil, + vertical: isLoading && height != nil + ) + .animation(.easeInOut(duration: Self.fadeDuration), value: isLoading) + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonShape.swift b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonShape.swift new file mode 100644 index 00000000..4b331ffb --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonShape.swift @@ -0,0 +1,26 @@ +// +// SkeletonShape.swift +// PickeDesignKit +// + +import SwiftUI + +/// 스켈레톤 자리표시자의 외곽 형태. +public enum SkeletonShape: Shape { + case rect + case round(cornerRadius: CGFloat = .radiusDefault) + case circle + + public func path(in rect: CGRect) -> Path { + switch self { + case .rect: + Rectangle().path(in: rect) + + case let .round(cornerRadius): + RoundedRectangle(cornerRadius: cornerRadius, style: .circular).path(in: rect) + + case .circle: + Circle().path(in: rect) + } + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift new file mode 100644 index 00000000..fc689f78 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift @@ -0,0 +1,76 @@ +// +// SkeletonView.swift +// PickeDesignKit +// + +import SwiftUI + +/// 로딩 중 콘텐츠 자리를 대신 채우는 시머 자리표시자. +public struct SkeletonView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let shape: SkeletonShape + private let baseColor: Color + private let highlightColor: Color + + /// 기본값은 베이지 배경 기준이다. 어두운 표면에서는 + /// `base: .gray600, highlight: .gray400` 처럼 어두운 쌍을 넘긴다. + public init( + _ shape: SkeletonShape, + base: Color = .beige600, + highlight: Color = .beige50 + ) { + self.shape = shape + baseColor = base + highlightColor = highlight + } + + public var body: some View { + shape + .fill(baseColor) + .overlay { + GeometryReader { + let size = $0.size + let shimmerWidth = size.width / 2 + + let blurRadius = max(shimmerWidth / 2, Self.minimumBlurRadius) + let blurDiameter = blurRadius * 2 + + let minX = -(shimmerWidth + blurDiameter) + let maxX = size.width + shimmerWidth + blurDiameter + + // 모션 감소를 켠 사용자에게는 훑는 움직임 없이 바탕색만 보여준다. + if !reduceMotion { + // TimelineView 는 뷰가 다시 만들어져도 시각에서 위상을 다시 구하므로 + // onAppear 로 위상을 몰아주던 방식과 달리 애니메이션이 끊기지 않는다. + TimelineView(.animation) { timeline in + Rectangle() + .fill(highlightColor) + .frame(width: shimmerWidth, height: size.height * 2) + .frame(height: size.height) + .blur(radius: blurRadius) + .rotationEffect(.degrees(Self.tiltDegrees)) + .blendMode(.softLight) + .offset(x: minX + (maxX - minX) * Self.phase(at: timeline.date)) + } + } + } + } + .clipShape(shape) + .compositingGroup() + .accessibilityIdentifier("skeleton_root") + } +} + +private extension SkeletonView { + /// 좁은 자리표시자에서도 번짐이 남도록 하는 최소 블러 반경. + static let minimumBlurRadius: CGFloat = 30 + /// 하이라이트를 살짝 기울여 사선으로 훑게 한다. + static let tiltDegrees: Double = 5 + /// 한 번 훑는 데 걸리는 시간(초). + static let period: Double = 1 + + static func phase(at date: Date) -> Double { + date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: period) / period + } +} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Toast/ToastManager.swift b/Projects/UI/PickeDesignKit/Sources/UI/Toast/ToastManager.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Toast/ToastManager.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Toast/ToastManager.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Toast/ToastType.swift b/Projects/UI/PickeDesignKit/Sources/UI/Toast/ToastType.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Toast/ToastType.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Toast/ToastType.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Toast/ToastView.swift b/Projects/UI/PickeDesignKit/Sources/UI/Toast/ToastView.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Toast/ToastView.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Toast/ToastView.swift diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Token/ComponentToken.swift b/Projects/UI/PickeDesignKit/Sources/UI/Token/ComponentToken.swift similarity index 100% rename from Projects/Shared/PickeDesignKit/Sources/UI/Token/ComponentToken.swift rename to Projects/UI/PickeDesignKit/Sources/UI/Token/ComponentToken.swift diff --git a/Projects/UI/PickeDesignKit/Tests/Sources/DesignTokenTests.swift b/Projects/UI/PickeDesignKit/Tests/Sources/DesignTokenTests.swift new file mode 100644 index 00000000..90087114 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Tests/Sources/DesignTokenTests.swift @@ -0,0 +1,41 @@ +// +// DesignTokenTests.swift +// PickeDesignKitTests +// + +import CoreGraphics +import Testing + +@testable import PickeDesignKit + +/// 토큰 값 자체는 생성기가 만든다. 여기서는 화면 코드가 기대는 관계만 고정한다. +struct DesignTokenTests { + @Test + func 간격_토큰은_단조_증가한다() { + let scale: [CGFloat] = [.s0, .s2, .s4, .s6, .s8, .s12, .s16, .s20, .s24, .s32, .s40, .s48, .s64, .s80, .s96] + + #expect(zip(scale, scale.dropFirst()).allSatisfy { $0 < $1 }) + } + + @Test + func 아이콘과_아바타_크기는_단계별로_커진다() { + #expect(CGFloat.iconXs < .iconSm) + #expect(CGFloat.iconSm < .iconMd) + #expect(CGFloat.iconMd < .iconLg) + #expect(CGFloat.avatarSm < .avatarMd) + #expect(CGFloat.avatarMd < .avatarLg) + } + + /// 알약 모양을 만드는 값이라 어떤 컴포넌트 높이보다도 커야 한다. + @Test + func radiusMax_는_기본_radius_보다_충분히_크다() { + #expect(CGFloat.radiusMax > .controlMd) + #expect(CGFloat.radiusDefault < .radiusMax) + } + + @Test + func 테두리_두께는_regular_medium_large_순이다() { + #expect(CGFloat.borderWidthRegular < .borderWidthMedium) + #expect(CGFloat.borderWidthMedium < .borderWidthLarge) + } +} diff --git a/Projects/UI/PickeDesignKit/Tests/Sources/ToastTypeTests.swift b/Projects/UI/PickeDesignKit/Tests/Sources/ToastTypeTests.swift new file mode 100644 index 00000000..cba2a362 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Tests/Sources/ToastTypeTests.swift @@ -0,0 +1,39 @@ +// +// ToastTypeTests.swift +// PickeDesignKitTests +// + +import Testing + +@testable import PickeDesignKit + +struct ToastTypeTests { + private let toasts: [ToastType] = [ + .success("성공"), + .error("실패"), + .warning("주의"), + .info("안내"), + .loading("불러오는 중"), + ] + + @Test + func message_는_어떤_종류든_연관값을_꺼낸다() { + #expect(toasts.map(\.message) == ["성공", "실패", "주의", "안내", "불러오는 중"]) + } + + /// 로딩만 아이콘 대신 인디케이터를 그린다. + @Test + func 로딩_토스트만_아이콘이_없다() { + #expect(ToastType.loading("불러오는 중").iconName == nil) + + let others = toasts.filter { $0 != .loading("불러오는 중") } + #expect(others.count == 4) + #expect(others.allSatisfy { $0.iconName != nil }) + } + + @Test + func 같은_종류_같은_문구면_같은_토스트다() { + #expect(ToastType.success("완료") == .success("완료")) + #expect(ToastType.success("완료") != .info("완료")) + } +} diff --git a/Projects/UI/PickeSharedUI/Project.swift b/Projects/UI/PickeSharedUI/Project.swift new file mode 100644 index 00000000..57a45356 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Project.swift @@ -0,0 +1,22 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +// 외부 라이브러리에 기대는 공용 UI 만 둔다. +// PickeDesignKit 은 토큰과 순수 컴포넌트만 갖고 라이브러리를 모른다. +let project = Project.makeModule( + name: "PickeSharedUI", + bundleId: .appBundleID(name: ".PickeSharedUI"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .ui(.designKit), + .SPM.composableArchitecture, + .SPM.kingfisher, + ], + hasTests: true +) \ No newline at end of file diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertModifiers.swift similarity index 97% rename from Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift rename to Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertModifiers.swift index c0a8a5a9..fe64d492 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertModifiers.swift @@ -6,6 +6,8 @@ import ComposableArchitecture import SwiftUI +import PickeDesignKit + public extension View { func customAlert( _ store: Binding, CustomAlertAction>?> diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift similarity index 99% rename from Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomAlertState.swift rename to Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift index 6c36976d..784130ed 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift @@ -6,6 +6,8 @@ import ComposableArchitecture import SwiftUI +import PickeDesignKit + @ObservableState public struct CustomAlertState: Equatable { public let title: String diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift new file mode 100644 index 00000000..c6597107 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift @@ -0,0 +1,101 @@ +// +// CustomConfirmationPopupView+Components.swift +// DesignSystem +// +// 팝업 공통 헬퍼 뷰 + +import SwiftUI + +import PickeDesignKit + +extension CustomConfirmationPopup { + /// 베이지 카드 + primary 보더 컨테이너 (width 313, top padding 20). + func pickeAlertCard( + opacity: Double = 1, + @ViewBuilder content: () -> some View + ) -> some View { + content() + .padding(.top, 20) + .frame(maxWidth: 313) + .pickeCard( + .beige500, + border: .primary500, + lineWidth: 1.5 + ) + .opacity(opacity) + .clipShape(RoundedRectangle(cornerRadius: .radiusDefault)) + .onTapGesture {} + } + + /// 본문 텍스트 (14/Medium, primary800, 가운데, 좌우 20). + func pickeBodyText(_ text: String) -> some View { + Text(text) + .pretendardFont(.labelMedium) + .foregroundStyle(.primary800) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + } + + /// 좌(밝은)·우(primary) 2버튼 행. 좌/우 의미는 호출부가 결정. + @ViewBuilder + func pickeTwoButtonRow( + leftTitle: String, + leftAction: @escaping () -> Void, + rightTitle: String, + rightAction: @escaping () -> Void + ) -> some View { + if dynamicTypeSize.isAccessibilitySize { + VStack(spacing: 8) { + pickeSecondaryButton(title: leftTitle, action: leftAction) + pickePrimaryButton(title: rightTitle, action: rightAction) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } else { + HStack(spacing: 8) { + pickeSecondaryButton(title: leftTitle, action: leftAction) + pickePrimaryButton(title: rightTitle, action: rightAction) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + @ViewBuilder + private func pickeSecondaryButton( + title: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .pretendardFont(.labelMedium) + .foregroundStyle(.primary800) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity) + .frame(minHeight: 48) + .roundedBackground(.secondary50, radius: 8) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func pickePrimaryButton( + title: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .pretendardFont(.labelMedium) + .foregroundStyle(.secondary50) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity) + .frame(minHeight: 48) + .roundedBackground(.primary500, radius: 8) + } + .buttonStyle(.plain) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift new file mode 100644 index 00000000..1ad5c10e --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift @@ -0,0 +1,279 @@ +// +// CustomConfirmationPopupView+Content.swift +// DesignSystem +// +// 팝업 종류별 컨텐츠 뷰 구성 + +import SwiftUI + +import PickeDesignKit + +extension CustomConfirmationPopup { + func popupMaxWidth(for containerWidth: CGFloat) -> CGFloat { + max(0, min(containerWidth - popupHorizontalPadding * 2, 360)) + } + + var popupHorizontalPadding: CGFloat { + switch style { + case .confirmation: + return 20 + case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic: + return 0 + } + } + + @ViewBuilder + var popupContent: some View { + switch style { + case .confirmation: + confirmationContent + case .finalVote: + finalVoteContent + case .report: + reportContent + case .alreadyWatched: + alreadyWatchedContent + case .deleteConfirm: + deleteConfirmContent + case .logout, .withdraw: + logoutWithdrawContent + case .suggestTopic: + suggestTopicContent + } + } + + /// 로그아웃/탈퇴 — 공통 팝업 스타일(무료 충전/다시 시청 팝업과 동일). + /// 본문 + 라운드 2버튼(확정=왼쪽 밝은 secondary50 / 취소=오른쪽 primary500). + var logoutWithdrawContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + pickeBodyText(title) + + pickeTwoButtonRow( + leftTitle: confirmTitle, leftAction: onConfirm, + rightTitle: cancelTitle, rightAction: onCancel + ) + } + } + } + + /// 주제 제안 — 타이틀 + 본문 + 뒤로가기(왼쪽 밝은) / 제안하기(오른쪽 primary). + var suggestTopicContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + VStack(spacing: 10) { + Text(title) + .pretendardFont(.headingMedium) + .foregroundStyle(.primary800) + .multilineTextAlignment(.center) + + if !message.isEmpty { + pickeBodyText(message) + } + } + + pickeTwoButtonRow( + leftTitle: cancelTitle, leftAction: onCancel, + rightTitle: confirmTitle, rightAction: onConfirm + ) + } + } + } + + var deleteConfirmContent: some View { + VStack(spacing: 16) { + Text(title) + .pretendardFont(.labelMedium) + .foregroundStyle(.neutral900) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + + HStack(spacing: 0) { + // 삭제하기 (왼쪽·밝은 버튼) = 확정(삭제) + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.primary500) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + // 뒤로가기 (오른쪽·어두운 버튼) = 취소 + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.secondary50) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 20) + .frame(maxWidth: 313) + .pickeCard( + .beige500, + border: .primary500, + lineWidth: 1.5 + ) + .opacity(0.9) + .clipShape(RoundedRectangle(cornerRadius: .radiusDefault)) + .onTapGesture {} + } + + @ViewBuilder + var alreadyWatchedContent: some View { + VStack(spacing: 12) { + VStack(spacing: 8) { + Text(title) + .pretendardFont(.headingMedium) + .foregroundStyle(.primary800) + .kerning(-0.4) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + if !message.isEmpty { + Text(message) + .pretendardFont(.medium13) + .foregroundStyle(.neutral400) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + + HStack(spacing: 0) { + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "취소" : cancelTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "다시" : confirmTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 24) + .frame(maxWidth: 343) + .pickeCard( + .beige500, + border: .primary500, + lineWidth: 1.5, + radius: 6 + ) + .onTapGesture {} + } + + var confirmationContent: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + Text(title) + .pretendardFont(.bold18) + .foregroundStyle(.neutral800) + .multilineTextAlignment(.center) + + if !message.isEmpty { + Text(message) + .pretendardFont(.regular13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .multilineTextAlignment(.center) + } + } + + HStack(spacing: 8) { + if !cancelTitle.isEmpty { + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.neutral500) + .frame(maxWidth: .infinity) + .frame(height: 48) + .roundedBackground(.beige600) + } + .buttonStyle(.plain) + } + + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + isDestructive ? Color.errorDefault : Color.primary500, + in: RoundedRectangle(cornerRadius: .radiusDefault) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 28) + .padding(.horizontal, 20) + .frame(maxWidth: 320) + .pickeCard(ComponentToken.Popup.background, border: ComponentToken.Popup.border) + .onTapGesture {} + } + + var finalVoteContent: some View { + VStack(spacing: 16) { + Text(title) + .pretendardFont(.labelMedium) + .foregroundStyle(.neutral900) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + + HStack(spacing: 0) { + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.primary500) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.secondary50) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 20) + .frame(maxWidth: 313) + .pickeCard( + .beige500, + border: .primary500, + lineWidth: 1.5 + ) + .opacity(0.9) + .onTapGesture {} + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView.swift new file mode 100644 index 00000000..88538125 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -0,0 +1,67 @@ +// +// CustomConfirmationPopupView.swift +// DesignSystem +// + +import SwiftUI + +import PickeDesignKit + +struct CustomConfirmationPopup: View { + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + let title: String + let message: String + let confirmTitle: String + let cancelTitle: String + let isDestructive: Bool + let style: CustomAlertStyle + let onConfirm: () -> Void + let onCancel: () -> Void + + @State private var isContentVisible = false + @State var selectedReason: ReportReason? + + init( + title: String, + message: String, + confirmTitle: String, + cancelTitle: String, + isDestructive: Bool, + style: CustomAlertStyle, + onConfirm: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self.title = title + self.message = message + self.confirmTitle = confirmTitle + self.cancelTitle = cancelTitle + self.isDestructive = isDestructive + self.style = style + self.onConfirm = onConfirm + self.onCancel = onCancel + } + + var body: some View { + GeometryReader { proxy in + ZStack { + Color.black + .opacity(isContentVisible ? 0.6 : 0) + .ignoresSafeArea() + .onTapGesture(perform: onCancel) + + popupContent + .frame(maxWidth: popupMaxWidth(for: proxy.size.width)) + .padding(.horizontal, popupHorizontalPadding) + .offset(y: isContentVisible ? 0 : 120) + .opacity(isContentVisible ? 1 : 0) + .accessibilityAddTraits(.isModal) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 0.3)) { + isContentVisible = true + } + } + } +} diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/AudioPlayer/AudioEqualizerView.swift b/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioEqualizerView.swift similarity index 98% rename from Projects/Shared/PickeDesignKit/Sources/UI/AudioPlayer/AudioEqualizerView.swift rename to Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioEqualizerView.swift index d88d600a..3001f171 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/AudioPlayer/AudioEqualizerView.swift +++ b/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioEqualizerView.swift @@ -5,6 +5,8 @@ import SwiftUI +import PickeDesignKit + public struct AudioEqualizerView: View { private let isPlaying: Bool private let barCount: Int diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/AudioPlayer/AudioPlayerControlView.swift b/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift similarity index 99% rename from Projects/Shared/PickeDesignKit/Sources/UI/AudioPlayer/AudioPlayerControlView.swift rename to Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift index ade00069..43d7684f 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/AudioPlayer/AudioPlayerControlView.swift +++ b/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift @@ -5,6 +5,8 @@ import SwiftUI +import PickeDesignKit + public struct AudioPlayerControlView: View { @Binding private var isPlaying: Bool private let onBackward: () -> Void diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Avatar/PickeAvatarView.swift b/Projects/UI/PickeSharedUI/Sources/Avatar/PickeAvatarView.swift similarity index 88% rename from Projects/Shared/PickeDesignKit/Sources/UI/Avatar/PickeAvatarView.swift rename to Projects/UI/PickeSharedUI/Sources/Avatar/PickeAvatarView.swift index c2b30179..f1c97000 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Avatar/PickeAvatarView.swift +++ b/Projects/UI/PickeSharedUI/Sources/Avatar/PickeAvatarView.swift @@ -5,7 +5,8 @@ import SwiftUI -import Kingfisher +import PickeDesignKit + /// 원형 아바타. 이미지가 없으면 이름 첫 글자를 대신 보여준다. public struct PickeAvatarView: View { @@ -34,10 +35,7 @@ public struct PickeAvatarView: View { @ViewBuilder private func avatarContent() -> some View { if let imageURL, let url = URL(string: imageURL) { - KFImage(url) - .placeholder { Color.beige600 } - .resizable() - .scaledToFill() + PickeRemoteImage(url: url) { Color.beige600 } .frame(width: 24, height: 24) .scaleEffect(imageScale) } else { diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Empty/PickeEmptyStateView.swift b/Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift similarity index 97% rename from Projects/Shared/PickeDesignKit/Sources/UI/Empty/PickeEmptyStateView.swift rename to Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift index b51b0c0b..809d5347 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Empty/PickeEmptyStateView.swift +++ b/Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift @@ -5,6 +5,8 @@ import SwiftUI +import PickeDesignKit + public struct PickeEmptyStateView: View { private let message: String private let imageAsset: ImageAsset diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Empty/PickeRetryErrorView.swift b/Projects/UI/PickeSharedUI/Sources/Empty/PickeRetryErrorView.swift similarity index 97% rename from Projects/Shared/PickeDesignKit/Sources/UI/Empty/PickeRetryErrorView.swift rename to Projects/UI/PickeSharedUI/Sources/Empty/PickeRetryErrorView.swift index 28a92941..9d14220a 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Empty/PickeRetryErrorView.swift +++ b/Projects/UI/PickeSharedUI/Sources/Empty/PickeRetryErrorView.swift @@ -5,6 +5,8 @@ import SwiftUI +import PickeDesignKit + public struct PickeRetryErrorView: View { private let message: String private let retryTitle: String diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Input/PickeCommentInputBar.swift b/Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift similarity index 98% rename from Projects/Shared/PickeDesignKit/Sources/UI/Input/PickeCommentInputBar.swift rename to Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift index 24a84636..50ff574f 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Input/PickeCommentInputBar.swift +++ b/Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift @@ -5,6 +5,8 @@ import SwiftUI +import PickeDesignKit + /// 하단 고정 댓글 입력 바. public struct PickeCommentInputBar: View { @Binding private var text: String diff --git a/Projects/Shared/PickeDesignKit/Sources/UI/Modal/PickeModal.swift b/Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift similarity index 97% rename from Projects/Shared/PickeDesignKit/Sources/UI/Modal/PickeModal.swift rename to Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift index 5cb8d318..b38abbbf 100644 --- a/Projects/Shared/PickeDesignKit/Sources/UI/Modal/PickeModal.swift +++ b/Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift @@ -5,6 +5,8 @@ import SwiftUI +import PickeDesignKit + import ComposableArchitecture public extension View { diff --git a/Projects/UI/PickeSharedUI/Sources/RemoteImage/PickeRemoteImage.swift b/Projects/UI/PickeSharedUI/Sources/RemoteImage/PickeRemoteImage.swift new file mode 100644 index 00000000..f65cf232 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/RemoteImage/PickeRemoteImage.swift @@ -0,0 +1,69 @@ +// +// PickeRemoteImage.swift +// PickeSharedUI +// + +import Kingfisher +import PickeDesignKit +import SwiftUI + +// MARK: - 원격 이미지 공용 컴포넌트 + +/// 이미지 로딩 라이브러리를 이 타입 안에 가둔다. +/// 화면 코드는 Kingfisher 를 import 하지 않는다. +public struct PickeRemoteImage: View { + private let url: URL? + private let placeholder: () -> Placeholder + + private var contentMode: SwiftUI.ContentMode = .fill + + public init( + url: URL?, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.url = url + self.placeholder = placeholder + } + + public init( + url: String?, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.init(url: url.flatMap(URL.init(string:)), placeholder: placeholder) + } + + public var body: some View { + KFImage(url) + .placeholder(placeholder) + .resizable() + .aspectRatio(contentMode: contentMode) + } +} + +public extension PickeRemoteImage { + /// `.fill` 이 기본이다. 잘리면 안 되는 이미지에만 `.fit` 을 준다. + func content(_ mode: SwiftUI.ContentMode) -> Self { + var image = self + image.contentMode = mode + return image + } +} + +// MARK: - 기본 자리표시자 + +public extension PickeRemoteImage where Placeholder == SkeletonView { + /// 자리표시자를 따로 주지 않으면 스켈레톤이 자리를 채운다. + init( + url: URL?, + shape: SkeletonShape = .round() + ) { + self.init(url: url) { SkeletonView(shape) } + } + + init( + url: String?, + shape: SkeletonShape = .round() + ) { + self.init(url: url) { SkeletonView(shape) } + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Report/CustomConfirmationPopupView+Report.swift b/Projects/UI/PickeSharedUI/Sources/Report/CustomConfirmationPopupView+Report.swift new file mode 100644 index 00000000..79c42b19 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Report/CustomConfirmationPopupView+Report.swift @@ -0,0 +1,131 @@ +// +// CustomConfirmationPopupView+Report.swift +// DesignSystem +// +// 신고 팝업 전용 컨텐츠 + +import SwiftUI + +import PickeDesignKit + +extension CustomConfirmationPopup { + @ViewBuilder + var reportContent: some View { + VStack(spacing: 16) { + reportHeader + reasonGrid + reportButtons + } + .padding(.top, 20) + .frame(maxWidth: 343) + .roundedBackground(.beige500, radius: 6) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .roundedBorder( + .primary500, + lineWidth: 1.5, + radius: 6 + ) + .onTapGesture {} + } + + @ViewBuilder + private var reportHeader: some View { + Text("신고사유") + .pretendardFont(.headingMedium) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + } + + @ViewBuilder + private var reasonGrid: some View { + HStack(alignment: .top, spacing: 0) { + reasonColumn(ReportReason.leftColumn) + reasonColumn(ReportReason.rightColumn, fillSpacer: true) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .frame(height: 116) + } + + @ViewBuilder + private func reasonColumn( + _ reasons: [ReportReason], + fillSpacer: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(reasons) { reason in + reasonRow(reason) + } + if fillSpacer { + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func reasonRow(_ reason: ReportReason) -> some View { + Button { + selectedReason = reason + } label: { + HStack(spacing: 6) { + reasonRadio(isSelected: selectedReason == reason) + Text(reason.title) + .pretendardFont(.labelMedium) + .foregroundStyle(.neutral900) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func reasonRadio(isSelected: Bool) -> some View { + ZStack { + Circle() + .stroke(.gray200, lineWidth: 3) + .frame(width: 20, height: 20) + if isSelected { + Circle() + .fill(.primary500) + .frame(width: 10, height: 10) + } + } + } + + @ViewBuilder + private var reportButtons: some View { + HStack(spacing: 10) { + reportSubmitButton + reportCancelButton + } + } + + @ViewBuilder + private var reportSubmitButton: some View { + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "신고하기" : confirmTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + .disabled(selectedReason == nil) + .opacity(selectedReason == nil ? 0.5 : 1) + } + + @ViewBuilder + private var reportCancelButton: some View { + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "뒤로가기" : cancelTitle) + .pretendardFont(.labelMedium) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Report/ReportReason.swift b/Projects/UI/PickeSharedUI/Sources/Report/ReportReason.swift new file mode 100644 index 00000000..66e65903 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Report/ReportReason.swift @@ -0,0 +1,30 @@ +// +// ReportReason.swift +// DesignSystem +// +// 신고 사유 enum + +import Foundation + +public enum ReportReason: String, CaseIterable, Identifiable, Equatable { + case commercial + case repeat_ + case explicit + case insult + case other + + public var id: String { rawValue } + + public var title: String { + switch self { + case .commercial: "영리목적/홍보성" + case .repeat_: "같은 내용 반복 게시" + case .explicit: "음란성/선정성" + case .insult: "욕설/인신공격" + case .other: "기타" + } + } + + static let leftColumn: [ReportReason] = [.commercial, .explicit, .other] + static let rightColumn: [ReportReason] = [.repeat_, .insult] +} diff --git a/Projects/UI/PickeSharedUI/Tests/Sources/CustomAlertStateTests.swift b/Projects/UI/PickeSharedUI/Tests/Sources/CustomAlertStateTests.swift new file mode 100644 index 00000000..8b1f9425 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Tests/Sources/CustomAlertStateTests.swift @@ -0,0 +1,40 @@ +// +// CustomAlertStateTests.swift +// PickeSharedUITests +// + +import Testing + +@testable import PickeSharedUI + +struct CustomAlertStateTests { + @Test + func 기본값은_확인_취소_비파괴_확인형이다() { + let sut = CustomAlertState(title: "제목") + + #expect(sut.message.isEmpty) + #expect(sut.confirmTitle == "확인") + #expect(sut.cancelTitle == "취소") + #expect(sut.isDestructive == false) + #expect(sut.style == .confirmation) + } + + @Test + func 지정한_값이_그대로_담긴다() { + let sut = CustomAlertState( + title: "탈퇴", + message: "되돌릴 수 없다", + confirmTitle: "탈퇴하기", + cancelTitle: "닫기", + isDestructive: true, + style: .withdraw + ) + + #expect(sut.title == "탈퇴") + #expect(sut.message == "되돌릴 수 없다") + #expect(sut.confirmTitle == "탈퇴하기") + #expect(sut.cancelTitle == "닫기") + #expect(sut.isDestructive) + #expect(sut.style == .withdraw) + } +} diff --git a/Projects/UI/PickeSharedUI/Tests/Sources/ReportReasonTests.swift b/Projects/UI/PickeSharedUI/Tests/Sources/ReportReasonTests.swift new file mode 100644 index 00000000..8eecdc98 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Tests/Sources/ReportReasonTests.swift @@ -0,0 +1,32 @@ +// +// ReportReasonTests.swift +// PickeSharedUITests +// + +import Testing + +@testable import PickeSharedUI + +struct ReportReasonTests { + /// 신고 사유를 추가하고 컬럼 배치를 잊으면 그 항목이 화면에서 통째로 사라진다. + @Test + func 두_컬럼이_모든_사유를_중복_없이_담는다() { + let placed = ReportReason.leftColumn + ReportReason.rightColumn + + #expect(Set(placed) == Set(ReportReason.allCases)) + #expect(placed.count == ReportReason.allCases.count) + } + + @Test + func 모든_사유가_서로_다른_제목을_갖는다() { + let titles = ReportReason.allCases.map(\.title) + + #expect(Set(titles).count == ReportReason.allCases.count) + #expect(titles.allSatisfy { !$0.isEmpty }) + } + + @Test + func id_는_rawValue_다() { + #expect(ReportReason.allCases.allSatisfy { $0.id == $0.rawValue }) + } +} diff --git a/README.md b/README.md index bd9b6602..75ecd7d5 100644 --- a/README.md +++ b/README.md @@ -2,460 +2,354 @@
-**가치관 충돌에서 시작하는 1:1 철학 배틀 플랫폼, Picke** +**가치관 충돌에서 시작하는 1:1 철학 배틀 플랫폼** ![Platform](https://img.shields.io/badge/Platform-iOS-orange.svg) -![Language](https://img.shields.io/badge/Language-Swift-FA7343.svg?logo=swift&logoColor=white) +![Swift](https://img.shields.io/badge/Swift-6-FA7343.svg?logo=swift&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-17.0+-34C759.svg) -![Xcode](https://img.shields.io/badge/Xcode-16.0+-007ACC.svg) -![TCA](https://img.shields.io/badge/Architecture-TCA-purple.svg) -![Tuist](https://img.shields.io/badge/Modularization-Tuist-blue.svg) -![Fastlane](https://img.shields.io/badge/fastlane-00F200.svg?logo=fastlane&logoColor=white) +![Architecture](https://img.shields.io/badge/Architecture-TCA-purple.svg) +![Tuist](https://img.shields.io/badge/Tuist-4.207.0-blue.svg) -[🎯 Features](#-주요-기능) | [🏗 Architecture](#-프로젝트-아키텍처) | [🚀 Quick Start](#-빠른-시작) | [🔐 OAuth Flow](#-oauth-인증-플로우) - ---- +[아키텍처](#아키텍처) · [모듈 그래프](#모듈-그래프) · [빠른 시작](#빠른-시작) · [개발 명령어](#개발-명령어)
-## 📖 프로젝트 소개 +## 프로젝트 + +Picke는 일상의 가치관 차이를 오늘의 배틀, 1:1 토론, 사전·사후 투표, 리캡 공유로 이어 주는 iOS 앱입니다. + +주요 기능: + +- Google, Kakao, Apple 기반 소셜 로그인 +- 오늘의 배틀, 사전 투표, 1:1 토론, 사후 투표, 리캡 공유 +- 홈 피드, 탐색, 검색, 큐레이팅 추천 +- 관점 등록, 대댓글, 좋아요, 신고 +- 마이페이지, 포인트, 무료 충전, 배틀 기록, 알림 설정 +- APNs 푸시 알림과 딥링크 라우팅 +- Mixpanel 분석, Google Mobile Ads, Sentry 오류 수집 + +## 아키텍처 + +SwiftUI와 The Composable Architecture를 기반으로 한 Clean Architecture 멀티 모듈 프로젝트입니다. Tuist가 프로젝트 생성, 모듈 의존성, 테스트 스킴, 바이너리 캐시 흐름을 관리합니다. + +~~~text +Projects/ +├── App/ # 앱 진입점, AppReducer, DI 조립, 리소스 +├── Feature/ +│ ├── FeatureAssembly/ # 전체 Feature 조립 결과를 앱에 제공 +│ ├── FeatureSharedUI/ # Feature 공통 UI +│ ├── Auth/ # 로그인 +│ ├── Home/ # 홈·출석 모달 +│ ├── Battle/ # 배틀 메인 +│ ├── Chat/ # 채팅·투표·관점·큐레이팅 +│ ├── Hifi/ # 탐색·검색 +│ ├── Notification/ # 알림 +│ ├── Profile/ # 마이페이지·설정·리캡 +│ ├── Splash/ # 스플래시·앱 업데이트 +│ └── Web/ # WebView +├── Domain/ +│ ├── DomainAssembly/ # 도메인별 라이브 의존성 조립 +│ ├── AuthDomain/ # 인증 도메인 +│ ├── BattleDomain/ # 배틀 도메인 +│ ├── CommentDomain/ # 댓글·대댓글 도메인 +│ ├── HomeDomain/ # 홈 도메인 +│ ├── NotificationDomain/ # 알림 도메인 +│ ├── PerspectiveDomain/ # 관점 도메인 +│ ├── ProfileDomain/ # 프로필 도메인 +│ ├── SearchDomain/ # 검색 도메인 +│ ├── AttendanceDomain/ # 출석 도메인 +│ └── AppUpdateDomain/ # 앱 업데이트 도메인 +├── Service/ +│ ├── ServiceAssembly/ # API·인증·분석·디바이스 서비스 조립 +│ ├── API/ # API 경로 상수 +│ ├── APIEndpoint/ # 요청 명세 +│ ├── PickeAuth/ # 인증 세션과 토큰 갱신 +│ ├── PickeAnalytics/ # 분석 이벤트 +│ ├── PickeConfig/ # 앱 환경 설정 +│ ├── DeviceService/ # 디바이스·푸시 토큰 +│ └── AudioPlayerService/ # 오디오 재생 +├── Core/ +│ ├── CoreAssembly/ # Core 구현 묶음 +│ ├── PickeNetwork/ # Alamofire 기반 네트워크 +│ ├── PickeStorage/ # Keychain 저장소 +│ ├── PickeCoreLogger/ # 로깅 +│ ├── PickeCoreUtility/ # 공통 Swift·TCA 유틸리티 +│ ├── PickeCoreUI/ # UI 기반 타입 +│ └── PickeThirdParty/ # 외부 패키지 재노출 +└── UI/ + ├── PickeDesignKit/ # 디자인 토큰·컴포넌트·리소스 + ├── PickeSharedUI/ # 앱 공통 화면 컴포넌트 + └── PickeAnimation/ # 이미지·애니메이션 리소스 +~~~ -**Picke** 는 일상의 가치관 차이를 1:1 토론으로 풀어내는 모바일 토론·투표 플랫폼입니다. -"오늘의 배틀" 주제에 대한 사전·사후 투표, 실시간 1:1 채팅 토론, 그리고 리캡 카드까지 한 흐름으로 이어집니다. +### 의존성 흐름 -> 💡 **왜 만들었나?** -> SNS 의 단방향 의견 표출 대신, 짧고 명확한 1:1 토론을 통해 -> "내가 왜 그렇게 생각하는지" 를 정리하고 다른 가치관을 마주하는 경험을 제공합니다. +~~~mermaid +flowchart TD + App --> FeatureAssembly + App --> DomainAssembly + App --> ServiceAssembly + + FeatureAssembly --> Features[Feature modules] + Features --> DomainInterfaces["*DomainInterface"] + Features --> UI[UI modules] + + DomainAssembly --> Domains[Domain modules] + Domains --> DomainInterfaces + Domains --> ServiceAssembly + + ServiceAssembly --> Services[Service modules] + ServiceAssembly --> CoreAssembly + Services --> CoreAssembly + + CoreAssembly --> Network[PickeNetwork] + CoreAssembly --> Storage[PickeStorage] + CoreAssembly --> Logger[PickeCoreLogger] +~~~ + +설계 원칙: + +- App은 FeatureAssembly, DomainAssembly, ServiceAssembly를 통해 앱 전체 의존성을 조립합니다. +- Feature 모듈은 화면, TCA Reducer, Coordinator를 소유하고 외부 IO는 Domain Interface와 UseCase를 통해 호출합니다. +- Domain 모듈은 기능별 Entity, Repository 계약, UseCase 구현을 소유합니다. +- ServiceAssembly는 API, 인증, 분석, 디바이스, 오디오 서비스를 CoreAssembly와 연결합니다. +- CoreAssembly는 Network, Storage, Logger, Utility 등 앱 기반 구현을 묶습니다. +- UI 계층은 디자인 토큰, 공통 컴포넌트, 애니메이션 리소스를 제공합니다. + +### 의존성 주입 + +별도 런타임 DI 컨테이너 대신 Point-Free Dependencies를 사용합니다. + +- 각 Domain·Service·Core 모듈은 필요한 `DependencyKey`와 기본값을 선언합니다. +- Assembly 모듈은 live 구현을 `DependencyValues`에 등록합니다. +- Feature는 Repository 구현체를 직접 알지 않고 UseCase 또는 Interface만 사용합니다. +- 테스트·프리뷰 기본값은 Interface 또는 Testing 타깃에서 관리합니다. + +## 모듈 그래프 + +~~~bash +./make graph # 외부 패키지·Demo를 제외하고 Tests·Testing·Interface를 포함한 모듈 그래프 +./make graph:prod # 외부 패키지·Demo·Tests를 제외한 제품 그래프 +~~~ + +TuistSpider에서 Picke와 주요 조립 모듈을 기준으로 내부 의존성을 확장한 그래프입니다. 외부 의존성은 숨기고, 의존하는 방향을 전체 깊이로 표시했습니다. + +
+Picke 전체 모듈 + +![Picke 전체 모듈 단계별 그래프](docs/grpah/Picke-grouped-Picke.png) + +![Picke 전체 모듈 그래프](docs/grpah/Picke-expanded-Picke.png) + +
-## 🛠 Setup +
+FeatureAssembly -### AI 도구 연동 +![FeatureAssembly 모듈 그래프](docs/grpah/Picke-expanded-FeatureAssembly.png) -프로젝트 규칙은 `AGENTS.md` / `CLAUDE.md` 에 정의되어 있습니다. +
-```bash -ln -s AGENTS.md CLAUDE.md -``` - -## ✨ 주요 기능 - -### 🔐 소셜 로그인 (server-mediated OAuth) -- **Google / Kakao**: WKWebView 기반 `authorize → code 가로채기` → 백엔드 토큰 교환 -- **Apple Sign-In**: `ASAuthorizationAppleIDProvider` 네이티브 통합 -- **자동 토큰 갱신**: `AccessTokenCredential` JWT exp 디코딩 + 만료 5분 전 자동 refresh -- **401 자동 처리**: `AuthInterceptor` 가 401 감지 → refresh 시도 → 실패 시 자동 로그아웃 알림 발송 -- **USER_404 강제 로그아웃**: `SessionInvalidationPlugin` 이 응답 바디 에러코드(`USER_404`) 감지 → Keychain/세션 정리 후 로그인 전환 - -### 🥊 오늘의 배틀 -- **사전 투표 → 1:1 채팅 토론 → 사후 투표** 의 한 흐름 -- **재투표** 로 가치관이 바뀌었는지 추적 -- **리캡 카드** 자동 생성 + 공유 -- **채팅방 오디오 재생** + 로딩 실패 시 상단 floating 오류 배너(`FloatingErrorView`) - -### 💬 토론 / 관점(댓글) -- 채팅방형 1:1 음성 토론 -- 관점(=댓글) 등록·수정·삭제 + 대댓글, 좋아요, 신고 -- 투표 진영(optionId)별 관점 등록 / 진영 탭 필터 -- 본인 글 "나" 표시 + 수정·삭제 메뉴, 등록·갱신 시 스켈레톤 - -### 🧭 탐색 / 큐레이팅 / 홈 -- 큐레이팅된 홈 피드 -- **흥미 기반 배틀 추천**(큐레이팅 화면) — `GET /battles/{id}/recommendations/interesting` -- 카테고리·태그 탐색 -- 토픽 검색 - -### 👤 마이페이지 -- 프로필 카드 / 보유 포인트 + **무료 충전**(리워드 광고) -- 포인트 내역, 내 배틀 기록, 내 콘텐츠 활동(댓글/좋아요), 공지사항·이벤트 -- **나의 철학자 유형(recap)** — 배틀 5개 미만 시 잠금 화면 분기, 애니메이션 레이더 차트 + 공유 -- 배틀 주제 제안, 알림 설정 -- **회원 탈퇴** — 탈퇴 사유(복수 선택) 입력 화면 분리, 제출 시 디바이스 토큰 해제 + 세션 종료 - -### 🔔 알림 (Notification) -- **알림받기** 목록 — 카테고리 탭(전체·콘텐츠·공지사항·이벤트) + 무한 스크롤 -- 탭 시 읽음 처리 / **모두 읽음** — `GET /api/v1/notifications`, 읽음은 `PATCH .../read·/read-all` -- **미읽음 빨간점** — 저장소 없이 서버 `GET /api/v1/notifications/unread` 값으로만 구동 - - 홈·마이·탐색이 각 화면 진입 시 `/unread` 호출 → 자체 Bool 뱃지 갱신, 읽음 처리 시 즉시 제거 - -### 📲 푸시 알림 / 딥링크 (APNs) -- **APNs 다이렉트 발송** (Firebase SDK 미사용) — 권한 요청·토큰 수신 후 `POST /api/v1/devices` 등록(`platform: "IOS"`), 로그아웃/탈퇴 시 해제 -- 알림 탭 → 페이로드(`type`/`url`)를 `PickeDeeplink` 로 변환해 배틀 상세 등으로 라우팅, 콜드 스타트 대기 딥링크 처리 -- **커스텀 URL scheme** `picke://battle/55` · `picke://perspective/45?commentId=678` (`onOpenURL`) - -### ⬆️ 앱 업데이트 안내 -- 스플래시에서 **App Store(iTunes lookup) 최신 버전 비교** → 업데이트 필요 시 안내 alert -- "지금 업데이트" → App Store, "나중에" → 정상 진입 - -### 💰 무료 충전 (리워드 광고) -- **GoogleMobileAds 리워드 동영상** 시청 → 포인트 충전 (광고 유닛 ID 는 `REWARD_AD_UNIT` config 주입) -- `RewardedAdClient`(UseCase) — 로드·표시·보상 콜백을 async 로 추상화 - -### 📊 행동 분석 (Mixpanel) -- `AnalyticsUseCase` — 타입 안전 이벤트 + PICKé 핵심 이벤트 명세서 준수(이벤트 통합 전략) -- `sign_up` / `battle_step`(pre_vote·audio_end·post_vote) / `report_action` / `community_action` / `ad_revenue` + 로그인 시 `identify` - -## 🏗 프로젝트 아키텍처 - -### 🎯 Clean Architecture × Tuist 멀티 모듈 - -``` -Picke-iOS/ -├── 📱 Projects/ -│ ├── App/ # 메인 애플리케이션 타겟 -│ │ ├── Sources/ -│ │ │ ├── Application/ # AppDelegate(APNs) / PushTokenStore / Deeplink 브리지 -│ │ │ ├── Di/ # WeaveDI 등록 (DiRegister, AppPresentationContextProvider) -│ │ │ ├── Reducer/ # TCA Root AppReducer -│ │ │ └── View/ # Root Views -│ │ └── Derived/ # Tuist 생성 plist -│ │ -│ ├── Presentation/ # 🎨 UI Layer -│ │ ├── Auth/ # 로그인 / 온보딩 / 인증 코디네이터 -│ │ ├── Battle/ # 오늘의 배틀 / 빠른 배틀 화면 모델과 메인 플로우 -│ │ ├── Chat/ # 투표 / 채팅방 / 관점·대댓글 / 큐레이팅 -│ │ ├── Hifi/ # 탐색·검색 기반 Hi-Fi 화면 -│ │ ├── Home/ # 홈 피드 / 추천 / 스켈레톤 (탭 라우팅·GNB 는 App 레이어로 이관) -│ │ ├── Notification/ # 알림받기 목록 / 카테고리 탭 / 미읽음 뱃지 -│ │ ├── Profile/ # 마이페이지 / 포인트 / 설정 / 탈퇴 / 배틀제안 / 배틀기록 / 콘텐츠활동 / 공지 / 리캡 / 무료충전 -│ │ ├── Splash/ # 스플래시 / 앱 업데이트 체크 -│ │ ├── Web/ # 약관 / 외부 링크 WebView -│ │ └── Presentation/ # 공통 프레젠테이션 유틸 -│ │ -│ ├── Domain/ # 🔥 Business Logic Layer — Presentation 처럼 feature별 마이크로 모듈 -│ │ ├── / # Auth · Battle · Comment · Home · Notification · Perspective · Profile · Search -│ │ │ ├── Interface/ # DomainInterface: Entity + Repository/UseCase 프로토콜 + DI 키 -│ │ │ ├── Sources/ # Domain: UseCase 구현 (pass-through 는 인터페이스로 소비) -│ │ │ └── Testing/ # DomainTesting: 목 -│ │ ├── Common/ # 공유 도메인 계약(BattlePerspective·CommentLikeResult·BattleTag 등) + 횡단 관심사 -│ │ └── Domain/ # 엄브렐라(@_exported) — App(DI 조립) 전용 -│ │ -│ ├── Data/ # 📡 Data Layer — feature별 마이크로 모듈 -│ │ ├── / # Auth · Battle · Comment · Home · Notification · Perspective · Profile · Search -│ │ │ └── Sources/ # Data(단일 타깃): API / Model / Service / Repository 폴더 내장 -│ │ └── Data/ # 엄브렐라(@_exported) — App(DI 조립) 전용 -│ │ # 공유 베이스(BaseResponseDTO·MoyaProvider.authorized·세션매니저)는 monolith Model/Repository 에 유지 -│ │ -│ ├── Network/ # 🌐 Network Layer -│ │ ├── Networking/ # 네트워크 클라이언트 export -│ │ ├── NetworkToken/ # TokenProviding / InMemoryTokenProvider (토큰 추상화) -│ │ ├── NetworkHeader/ # APIHeader / APIHeaderManger (HTTP 헤더 조립, NetworkToken 의존) -│ │ └── ThirdPartys/ # AsyncMoya / WeaveDI 등 SPM 재노출 -│ │ -│ └── Shared/ # 🔧 Shared Layer -│ ├── DesignSystem/ # 공통 UI / 컬러 토큰 / 이미지 / Toast / Floating 배너 / 팝업 -│ ├── Shared/ # 공유 모델·확장 -│ ├── ThirdParty/ # 써드파티 래퍼 -│ └── Utill/ # 날짜 / 숫자 / 문자열 표시 유틸리티 -│ -├── 🔧 Tuist/ -│ ├── Package.swift # SPM 의존성 정의 -│ └── ProjectDescriptionHelpers/ # 모듈 템플릿 / Plist 헬퍼 -└── 🧩 Plugins/ - ├── DependencyPlugin/ # 모듈 의존성 헬퍼 (.Data / .Domain / .Network ...) - ├── DependencyPackagePlugin/ # SPM 의존성 헬퍼 (.SPM.asyncMoya ...) - └── ProjectTemplatePlugin/ # ProjectConfig / Project.makeModule -``` - -### 🏛️ Clean Architecture Pattern - -```mermaid -graph TD - A["Presentation/<Feature>"] --> B["<Feature>DomainInterface (Entity + 프로토콜 + DI 키)"] - A2["<Feature>Domain (UseCase 구현)"] --> B - C["<Feature>Data (API/Model/Service/Repository)"] --> B - C --> CM["Common (공유 계약)"] - B --> CM - C --> E["Network: NetworkHeader / NetworkToken / AsyncMoya"] - G[Shared: PickeDesignKit / Utill] --> A - App["App (DI 조립)"] --> UMB["Domain·Data 엄브렐라 @_exported"] - - A -.->|.interface 만 의존| B -``` - -> **핵심**: Presentation·Data 는 `DomainInterface`(프로토콜·DI 키)에만 의존 → 구현 변경이 소비자를 리빌드시키지 않는다. Auth 의 OAuth 오케스트레이션도 `AuthUseCaseInterface`/`UnifiedOAuthUseCaseInterface` 로 추출해 Presentation 이 구현이 아닌 인터페이스에 의존하도록 통일했다. - -### 🕸️ TuistSpider 확장 뷰 - -레이어별로 묶어 보거나(Grouped) 모든 모듈을 펼쳐 본(Expanded) 시각화입니다. (TuistSpider 결과) +
+DomainAssembly -
+![DomainAssembly 모듈 그래프](docs/grpah/Picke-expanded-DomainAssembly.png) -| Grouped | Expanded | -|:---:|:---:| -| | | +
- +
+ServiceAssembly -### 🔄 의존성 방향 원칙 - -``` -Presentation/DomainInterface (프로토콜 + DI 키) [.interface 만 의존] - ↓ -Domain (UseCase 구현) → DomainInterface + Common - ↓ -Data (Repository/Service/Model/API) → DomainInterface + Common + Network - ↓ -공유 계약(BattlePerspective·CommentLikeResult·BattleTag 등) → Common - ↓ -Network 인프라(NetworkHeader → NetworkToken, MoyaProvider.authorized) → 브리지 경유 -``` - -> Presentation 피처 간 화면 전환 계약은 각 `Interface`(예: `HomeDelegate`·`HifiDelegate`·`ChatInterface`)에 두어, 소비자가 구현 타깃을 import 하지 않고도 라우팅한다. - -**핵심 설계 원칙** -- ✅ **Presentation** 은 Domain 의 UseCase 인터페이스에 의존합니다. -- ✅ **Domain/UseCase** 는 Repository Protocol 을 통해 외부 IO 를 호출합니다. -- ✅ **Data/Repository** 는 Domain 의 Repository Protocol 을 구현하고 Entity 를 반환합니다. -- ✅ **Data/Model** 은 DTO 와 Entity 변환을 담당합니다. -- ✅ **Data/Service** 는 endpoint / method / parameter 정의만 담당합니다. -- ✅ 모든 데이터 흐름은 **Domain 을 중심**으로 진행합니다. - -## 🔐 OAuth 인증 플로우 - -### Google / Kakao — WKWebView server-mediated OAuth - -``` -앱 - │ authorize URL (response_type=code, redirect_uri=https://picke.store/oauth/

) - ▼ -WKWebView (OAuthWebViewController) - │ 사용자 동의 → 구글/카카오가 redirect_uri 로 302 - │ WKNavigationDelegate.decidePolicyFor 가 picke.store/oauth/

?code=... 가로채기 - │ decisionHandler(.cancel) ← 401 응답 송신 차단 - ▼ -authorizationCode 추출 → dismiss - ▼ -UnifiedOAuthUseCase - │ POST /api/v1/auth/login/ - │ body: { authorizationCode, redirectUri } - ▼ -AuthRepositoryImpl - │ BaseResponseDTO 디코딩 → LoginEntity - ▼ -KeychainManager 저장 + AuthSessionManager.credential 갱신 -``` - -### Apple — 네이티브 Sign-In - -`ASAuthorizationAppleIDProvider` 로 받은 credential / nonce / authorizationCode 를 그대로 백엔드에 전달. - -### 토큰 자동 갱신 - -- `AccessTokenCredential` 가 access token JWT 의 `exp` 를 디코딩해 만료 시점 보관 -- `AuthInterceptor.adapt` 에서 만료 5분 전이면 `TokenRefreshManager` 가 단일화된 refresh 수행 -- 401 응답 시 `retry` 로 토큰 갱신 후 재시도, 실패 시 `NSNotification.refreshTokenExpired` 발송 + 자동 로그아웃 - -## 🛠 기술 스택 - -### Core Technologies -- **🎯 Architecture**: The Composable Architecture (TCA) -- **📦 Modularization**: Tuist 4.x (Micro Feature Architecture) -- **💉 Dependency Injection**: WeaveDI 3.4.1 -- **🔀 Navigation**: TCAFlow (커스텀) -- **⚡ Concurrency**: Swift Concurrency (async/await) - -### 📚 주요 라이브러리 - -#### 🎯 아키텍처 & 상태 관리 -- **[ComposableArchitecture](https://github.com/pointfreeco/swift-composable-architecture)** — 단방향 상태 관리 -- **[TCAFlow](https://github.com/Roy-wonji/TCAFlow.git)** ⭐️ — TCA 기반 화면 전환 / 네비게이션 (커스텀) -- **[WeaveDI](https://github.com/Roy-wonji/WeaveDI.git)** ⭐️ — 의존성 주입 컨테이너 (커스텀) - -#### 🔐 인증 & 보안 -- **AuthenticationServices** — Apple Sign-In, ASWebAuthenticationSession -- **WebKit** — WKWebView 기반 server-mediated OAuth (Google / Kakao) -- **[AppAuth-iOS](https://github.com/openid/AppAuth-iOS.git)** — OAuth 2.0 / OpenID Connect 클라이언트 (옵션) - -#### 🌐 네트워킹 -- **[AsyncMoya](https://github.com/Roy-wonji/AsyncMoya)** ⭐️ — async/await 기반 HTTP 클라이언트 (커스텀) -- **Alamofire / Moya** — AsyncMoya 의 기반 스택 - -#### 🎨 UI & UX -- **SwiftUI** — 선언형 UI -- **[SDWebImageSwiftUI](https://github.com/SDWebImage/SDWebImageSwiftUI.git)** — 비동기 이미지 로딩 / 캐싱 - -#### 🔥 백엔드 / 분석 -- **[Firebase iOS SDK](https://github.com/firebase/firebase-ios-sdk)** — Crashlytics / Messaging -- **[Mixpanel](https://github.com/mixpanel/mixpanel-swift.git)** — 행동 분석 / Session Replay -- **[Google Mobile Ads](https://github.com/googleads/swift-package-manager-google-mobile-ads)** — 광고 - -### 🛠 개발 도구 & 유틸리티 - -#### 📊 로깅 & 디버깅 -- **LogMacro** — 커스텀 로깅 매크로 -- **IssueReporting** — 개발 단계 이슈 추적 -- **XCTestDynamicOverlay** — 테스트 환경 오버레이 - -#### ⚡ 성능 & 동시성 -- **Clocks** — 시간 관련 유틸리티 -- **ConcurrencyExtras** — Swift Concurrency 확장 -- **Swift 6.0** — 최신 Swift 언어 기능 - -#### 🔧 빌드 & 배포 -- **Tuist** — 프로젝트 생성 / 모듈 의존성 관리 -- **Swift Package Manager** — 패키지 의존성 관리 -- **fastlane + Bundler** — TestFlight / App Store 빌드·업로드 자동화 - -### 📱 지원 환경 -- **💻 Xcode**: 16.0 이상 -- **📱 iOS**: 17.0 이상 -- **⚡ Swift**: 6.0 이상 -- **🔧 Tuist**: 4.x 이상 - -## 🚀 빠른 시작 - -### ✅ 필수 요구사항 -- **💻 Xcode**: 16.0 이상 -- **📱 iOS**: 17.0 이상 -- **⚡ Swift**: 6.0 이상 -- **🔧 Tuist**: 4.x 이상 - -### 🛠 설치 및 실행 - -#### 1️⃣ 저장소 클론 -```bash -git clone https://github.com/Roy-wonji/Picke-iOS.git -cd Picke-iOS -``` - -#### 2️⃣ Tuist 설치 -```bash -curl -Ls https://install.tuist.io | bash -``` - -#### 3️⃣ Ruby / fastlane 의존성 설치 -```bash -rbenv install 3.3.9 -rbenv global 3.3.9 -bundle install -``` - -#### 4️⃣ 프로젝트 빌드 / 생성 -```bash -# 전체 워크플로우 (권장) -./make build # clean → install → generate - -# 단계별 실행 -./make clean # 빌드 산출물 정리 -./make install # SPM 의존성 설치 -./make generate # Xcode 프로젝트 생성 -``` - -#### 5️⃣ Xcode 열기 -```bash -open Picke.xcworkspace -``` +![ServiceAssembly 모듈 그래프](docs/grpah/Picke-expanded-ServiceAssembly.png) + +

+ +
+CoreAssembly + +![CoreAssembly 모듈 그래프](docs/grpah/Picke-expanded-CoreAssembly.png) + +
+ +
+PickeSharedUI + +![PickeSharedUI 모듈 그래프](docs/grpah/Picke-expanded-PickeSharedUI.png) + +
+ +## 기술 스택 + +| 영역 | 기술 | +|---|---| +| 언어 | Swift 6, Swift Concurrency | +| UI | SwiftUI | +| 상태 관리 | The Composable Architecture 1.26.2 | +| 내비게이션 | TCAFlow 1.1.8 | +| 프로젝트 | Tuist 4.207.0, Mise | +| 의존성 주입 | Point-Free Dependencies | +| 네트워크 | PickeNetwork, Alamofire | +| 로컬 데이터 | SQLiteData | +| 인증 | Sign in with Apple, GoogleSignIn, AppAuth | +| 저장소 | PickeStorage, Keychain | +| 이미지 | SDWebImageSwiftUI, Kingfisher | +| 모니터링 | Firebase Crashlytics, Sentry | +| 분석·광고 | Mixpanel, Google Mobile Ads | +| 테스트 | Swift Testing, XCTest, Tuist | + +의존성 버전의 실제 기준은 [Tuist/Package.swift](Tuist/Package.swift)와 [Tuist/Package.resolved](Tuist/Package.resolved)입니다. 보조 패키지 `swift-dependencies`는 Tuist에서 확인된 traits 조건부 의존성 누락 문제를 피하도록 `1.12.0`에 고정했습니다. 이 제한은 소스 빌드와 외부 모듈 캐시 모두에 적용됩니다. + +지원 환경: + +- iOS 17.0 이상 +- Swift 6 +- Xcode 26 이상 +- iPhone + +## 빌드 환경 + +로컬 확인 기준: + +- Xcode 26.5 +- Apple Swift 6.3.2 +- Tuist 4.207.0 -### ⚙️ 환경 설정 +Tuist 버전은 [mise.toml](mise.toml)에 고정되어 있습니다. -다음 키들을 `Picke-Dev.xcconfig` / `Picke-Stage.xcconfig` / `Picke-Prod.xcconfig` 에 채워주세요. +~~~bash +mise install +mise exec -- tuist version +~~~ -```xcconfig +빌드 환경별 xcconfig에 필요한 값을 채웁니다. + +~~~xcconfig BASE_URL = picke.store GOOGLE_CLIENT_ID = YOUR_GOOGLE_WEB_CLIENT_ID GOOGLE_IOS_CLIENT_ID = YOUR_GOOGLE_IOS_CLIENT_ID REVERSED_CLIENT_ID = YOUR_REVERSED_CLIENT_ID KAKAO_REST_API_KEY = YOUR_KAKAO_REST_API_KEY -``` - -### 🌐 OAuth 사전 등록 - -| Provider | redirect_uri | 비고 | -|---|---|---| -| Google | `https://picke.store/oauth/google` | Web client ID + Google Cloud Console 등록 필요 | -| Kakao | `https://picke.store/oauth/kakao` | Kakao Developers 콘솔 등록 필요 | -| Apple | (네이티브) | App Store Connect → Sign in with Apple | - -## 🛠️ 주요 명령어 - -### 🔄 기본 워크플로우 -```bash -./make build # 전체 빌드 프로세스 (권장) -./make generate # 프로젝트 생성만 -./make clean # 빌드 산출물 정리 -./make install # 의존성 설치 -``` - -### 🚨 문제 해결 -```bash -tuist clean # Tuist 캐시 정리 -./make clean # 모든 빌드 파일 정리 -``` - -### 🔍 코드 품질 / 그래프 -```bash -tuist graph # 의존성 그래프 생성 -tuist test # 전체 테스트 실행 -``` - -### 🚀 배포 -```bash -export MATCH_KEYCHAIN_PASSWORD="" -bundle exec fastlane ios QA # TestFlight 업로드 -bundle exec fastlane ios release # App Store 배포 -``` - -`MATCH_KEYCHAIN_PASSWORD`가 설정되어 있으면 fastlane이 `match_keychain`을 먼저 unlock해서 macOS 키체인 비밀번호 팝업을 줄입니다. - -## 📄 라이선스 - -이 프로젝트는 **MIT 라이선스** 하에 배포됩니다. -자세한 내용은 [LICENSE](LICENSE) 파일을 참고하세요. - -## 👥 팀 & 크레딧 - -### 💻 개발팀 -- **iOS Lead Developer**: 서원지 ([@Roy-wonji](https://github.com/Roy-wonji)) - -### 🛠 기술 스택 -- **iOS**: - ![Swift](https://img.shields.io/badge/swift-F05138?style=for-the-badge&logo=swift&logoColor=white) - ![Xcode](https://img.shields.io/badge/xcode-147EFB?style=for-the-badge&logo=xcode&logoColor=white) - ![Fastlane](https://img.shields.io/badge/fastlane-00F200?style=for-the-badge&logo=fastlane&logoColor=white) - -- **Server**: - ![AWS EC2](https://img.shields.io/badge/amazonec2-FF9900?style=for-the-badge&logo=amazonec2&logoColor=white) - ![AWS](https://img.shields.io/badge/amazonaws-232F3E?style=for-the-badge&logo=amazonaws&logoColor=white) - ![Swagger](https://img.shields.io/badge/swagger-85EA2D?style=for-the-badge&logo=swagger&logoColor=white) - -- **Design**: - ![Figma](https://img.shields.io/badge/figma-F24E1E?style=for-the-badge&logo=figma&logoColor=white) - -- **VCS**: - ![Git](https://img.shields.io/badge/git-F05032?style=for-the-badge&logo=git&logoColor=white) - ![GitHub](https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white) - -## 🐈‍⬛ Git 브랜칭 전략 - -### 1️⃣ Git Branching Strategy -- **main**: 프로덕션 배포용 -- **develop**: 개발 통합 브랜치 -- **feature/***: 기능별 개발 브랜치 -- **fix/***: 버그 픽스 브랜치 - -### 📋 워크플로우 -1. **develop** 에서 **feature/** 브랜치 생성 -2. 기능 개발 → 자체 커밋 단위 SRP 분리 -3. **feature/** → **develop** Pull Request, 코드 리뷰 -4. **develop** → **main** 배포 Pull Request - -### ✍️ 커밋 메시지 -- 한국어 사용 -- 관련 GitHub 이슈 번호 매칭 (예: `#20 #2`) -- 형식: `: <요약> #` -- `feat / fix / refactor / chore / docs / test` - -## 📞 문의 및 지원 -- 📧 **이메일**: suhwj81@gmail.com -- 🐛 **버그 신고**: [Issues](https://github.com/Roy-wonji/Picke-iOS/issues) -- 💡 **기능 제안**: [Discussions](https://github.com/Roy-wonji/Picke-iOS/discussions) - ---- +REWARD_AD_UNIT = YOUR_REWARD_AD_UNIT +~~~ -
+OAuth redirect URI는 서버 중계 흐름을 기준으로 등록합니다. -**Made with ❤️ by Picke Team** +| Provider | Redirect URI | +|---|---| +| Google | `https://picke.store/oauth/google` | +| Kakao | `https://picke.store/oauth/kakao` | +| Apple | Native Sign in with Apple | -[![Star this repo](https://img.shields.io/github/stars/Roy-wonji/Picke-iOS?style=social)](https://github.com/Roy-wonji/Picke-iOS) +## Tuist Dashboard와 캐시 -
+이 저장소는 로컬 개발에서만 Tuist Dashboard 프로젝트 `picke2026/picke`를 사용합니다. 일반 generate/project 확인은 Dashboard에 연결해 메트릭을 남기고, 바이너리 캐시 warm은 로컬 저장소만 사용합니다. CI에서는 대시보드 연결과 캐시 준비를 비활성화하며, 별도 CI 캐시 연동 설정은 추가하지 않습니다. + +[Tuist.swift](Tuist.swift)의 기준 설정: + +- 로컬 generate/project show: `fullHandle = "picke2026/picke"`, 기본 모듈 캐시 프로필 `.onlyExternal` +- 로컬 cache warm: `TUIST_LOCAL_CACHE_ONLY=true` 환경에서만 `fullHandle = nil`, 기본 모듈 캐시 프로필 `.onlyExternal` +- CI: `fullHandle = nil`, 기본 모듈 캐시 프로필 `.none` +- Xcode 컴파일 캐시: 현재 Explicit Modules를 끈 빌드 설정과 호환되지 않아 `enableCaching = false`, `cache.upload = false`로 비활성화 +- 인증: `optionalAuthentication = true`로 설정해 로그인되지 않은 환경에서도 generate가 실패하지 않도록 유지 + +로컬에서 Dashboard 연결과 로컬 바이너리 캐시를 준비하려면 `./make setup`을 실행합니다. 이 명령은 mise 도구 설치 후 Tuist 로그인을 확인하고, 로그인되어 있지 않으면 `tuist auth login`을 실행한 뒤 `Tuist.swift`의 `picke2026/picke` 연결을 `tuist project show`로 확인합니다. 이후 의존성을 설치하고 외부 모듈 바이너리 캐시를 로컬 저장소에 준비한 다음 프로젝트를 생성합니다. + +~~~bash +./make setup +~~~ + +`./make generate`, `./make test`, `./make cache`도 로컬에서는 필요한 Tuist Dashboard 인증과 프로젝트 확인을 먼저 수행합니다. 바이너리 캐시를 쓰지 않을 때는 Tuist 옵션을 그대로 전달합니다. + +~~~bash +./make generate --no-binary-cache --no-open +./make test --no-binary-cache +~~~ + +`./make cache`와 `./make cache:setup`은 기본적으로 `TUIST_LOCAL_CACHE_ONLY=true tuist cache warm --external-only`를 실행해 외부 의존성 중심으로 로컬 캐시를 데웁니다. CI에서는 Dashboard 인증, 프로젝트 확인, 캐시 준비를 건너뛰며, 별도 CI 캐시 연동은 하지 않습니다. + +Fastlane이나 CI용 래퍼처럼 캐시가 필요 없는 자동화 경로에서는 `tuist generate --no-binary-cache --no-open` 형태로 실행합니다. + +## 빠른 시작 + +~~~bash +git clone git@github.com:SWYP-Find/Picke-iOS.git +cd Picke-iOS + +./make setup +open Picke.xcworkspace +~~~ + +Xcode에서 `Picke-Stage` 또는 필요한 스킴과 사용할 iPhone 시뮬레이터를 선택해 실행합니다. + +`CLAUDE.md`가 필요한 도구는 `AGENTS.md`와 같은 내용을 보도록 심볼릭 링크를 만들 수 있습니다. + +~~~bash +ln -s AGENTS.md CLAUDE.md +~~~ + +## 개발 명령어 + +~~~bash +./make setup # mise 설치, Dashboard 확인, install, 외부 캐시 준비, generate +./make generate # Demo 앱을 포함해 Xcode 프로젝트 생성 +./make generate --no-open # Xcode를 열지 않고 프로젝트 생성 +./make generate --no-binary-cache --no-open +./make build # clean, install, generate +./make install # 의존성 설치 후 generate +./make test # 전체 테스트 +./make test --no-binary-cache # 로컬 바이너리 캐시 없이 전체 테스트 +./make cache # 외부 바이너리 캐시 준비 +./make cache:setup # 외부 바이너리 캐시 준비(cache 별칭) +./make format # SwiftFormat 적용 +./make lint # SwiftFormat 검사 +./make clean # 생성 프로젝트 정리 +./make reset # DerivedData 정리 후 프로젝트 재생성 +~~~ + +훅 호환용 `make test`는 실제 테스트를 실행하지 않고 스킵 메시지만 출력합니다. 실제 테스트는 `./make test` 또는 `mise exec -- tuist test`를 사용합니다. + +새 모듈 생성: + +~~~bash +./make feature <이름> +./make core <이름> +./make service <이름> +./make domain <이름> +./make ui <이름> +./make module <레이어> <이름> + +# 자동으로 만든 카탈로그 case가 원하는 이름과 다를 때 +./make feature <이름> --case <케이스명> +~~~ + +모듈 생성 명령은 scaffold뿐 아니라 모듈 카탈로그와 해당 레이어 Assembly 의존성도 함께 갱신합니다. + +## 배포와 자동화 + +- Fastlane QA와 release 레인은 빌드 번호를 갱신한 뒤 `tuist generate --no-binary-cache --no-open`로 워크스페이스를 재생성합니다. +- workspace가 없을 때 fallback 경로는 `mise exec -- tuist install` 후 `./make generate --no-binary-cache --no-open`을 실행합니다. +- Bitrise 배포 워크플로는 Fastlane을 통해 TestFlight 또는 App Store 제출을 수행합니다. +- CI에는 Tuist Dashboard 원격 캐시 연동을 추가하지 않습니다. + +## 개발 가이드 + +- [TCA 패턴](docs/agent/tca-patterns.md) +- [SwiftUI 패턴](docs/agent/swiftui-patterns.md) +- [TCAFlow 네비게이션](docs/agent/tcaflow-navigation.md) +- [DI 가이드](docs/agent/dependency-injection.md) +- [Micro Feature 진행 현황](docs/agent/micro-feature-migration-progress.md) +- [도메인/데이터/피처 아키텍처](docs/domain-data-feature-architecture.md) + +## 브랜치 전략 + +- `main`: 프로덕션 배포 +- `develop`: 개발 통합 +- `feature/*`: 기능 작업 +- `fix/*`: 버그 수정 + +작업 브랜치에서 검증 후 `develop`으로 Pull Request를 올립니다. + +## 문의 + +- Issues: [github.com/SWYP-Find/Picke-iOS/issues](https://github.com/SWYP-Find/Picke-iOS/issues) +- Discussions: [github.com/SWYP-Find/Picke-iOS/discussions](https://github.com/SWYP-Find/Picke-iOS/discussions) diff --git a/Tools/TokenGenerator.swift b/Tools/TokenGenerator.swift index f3a4dce7..dd0cf2c0 100644 --- a/Tools/TokenGenerator.swift +++ b/Tools/TokenGenerator.swift @@ -44,8 +44,8 @@ for url in [primitiveURL, semanticURL, componentURL] { // MARK: - Output paths -let sourcesDir = "\(cwd)/Projects/Shared/DesignSystem/Sources" -let colorOut = "\(sourcesDir)/Color/ShapeStyle+.swift" +let sourcesDir = "\(cwd)/Projects/UI/PickeDesignKit/Sources" +let colorOut = "\(sourcesDir)/Token/Color/ShapeStyle+.swift" let cgfloatDir = "\(sourcesDir)/Extension/CGFloat" let radiusOut = "\(cgfloatDir)/CGFloat+Radius+.swift" let spacingOut = "\(cgfloatDir)/CGFloat+Spacing+.swift" diff --git a/Tuist.swift b/Tuist.swift index 444ea05a..67c4e8e5 100644 --- a/Tuist.swift +++ b/Tuist.swift @@ -1,43 +1,38 @@ +import Foundation import ProjectDescription -let tuist = Tuist( - project: .tuist( - compatibleXcodeVersions: .all, - swiftVersion: .some("6.0.0"), - plugins: [ - .local(path: .relativeToRoot("Plugins/ProjectTemplatePlugin")), - .local(path: .relativeToRoot("Plugins/DependencyPackagePlugin")), - .local(path: .relativeToRoot("Plugins/DependencyPlugin")), - ], - generationOptions: .options( - // 🔒 패키지 버전 잠금 비활성화 여부 (기본 false) - // true = Package.resolved 고정 무시(최신으로 다시 풀기) - // false = 기존 잠금 유지(권장) - disablePackageVersionLocking: false, - - // ⚠️ 사이드 이펙트(스크립트 등) 경고를 어떤 타겟에 표시할지 - // .all / .selected([...]) / .none - staticSideEffectsWarningTargets: .all - - // 🆕 4.174.0+ 전용 옵션 (현재 mise.toml 의 4.154.0 에서는 미지원) - // 패키지에서 선언한 Swift 버전을 존중하고 자동으로 설정 - // defaultSwiftVersion: "6.0.0", - - // 🧰 Xcode 기본 빌드 구성(스킴 선택 기본값) - // defaultConfiguration: .debug, // 또는 .release +let usesLocalCacheOnly = ProcessInfo.processInfo.environment["TUIST_LOCAL_CACHE_ONLY"] == "true" - // 🔐 인증이 없더라도 명령이 실패하지 않도록 허용(Cloud 기능은 건너뜀) - // optionalAuthentication: .none, - - // 📊 빌드 인사이트(메트릭 전송) 비활성화 - // buildInsightsDisabled: false, - - // 🧪 SwiftPM 샌드박스 비활성화(특수 환경 외에는 권장하지 않음) - // disableSandbox: false, - - // 🧯 워크스페이스에 "tuist generate" 스킴 포함(Xcode 내에서 재생성 버튼처럼 사용) - // includeGenerateScheme: false - ), - installOptions: .options() +let tuist = Tuist( + // 일반 로컬 generate/project show 는 Dashboard 에 연결하고, + // cache warm 프로세스만 로컬 저장소를 쓰도록 handle 을 비운다. + fullHandle: Environment.isCI || usesLocalCacheOnly ? nil : "picke2026/picke", + cache: .cache(upload: false), + project: .tuist( + compatibleXcodeVersions: .all, + swiftVersion: .some("6.0.0"), + plugins: [ + .local(path: .relativeToRoot("Plugins/ProjectTemplatePlugin")), + .local(path: .relativeToRoot("Plugins/DependencyPackagePlugin")), + .local(path: .relativeToRoot("Plugins/DependencyPlugin")), + ], + generationOptions: .options( + // 🔒 패키지 버전 잠금 비활성화 여부 (기본 false) + // true = Package.resolved 고정 무시(최신으로 다시 풀기) + // false = 기존 잠금 유지(권장) + disablePackageVersionLocking: false, + + // ⚠️ 사이드 이펙트(스크립트 등) 경고를 어떤 타겟에 표시할지 + // .all / .selected([...]) / .none + staticSideEffectsWarningTargets: .all, + optionalAuthentication: true, + // 현재 Explicit Modules를 끈 빌드 설정에서는 Xcode 컴파일 캐시를 사용할 수 없다. + // 로컬 개발은 아래의 외부 모듈 바이너리 캐시를 사용한다. + enableCaching: false + ), + installOptions: .options(), + cacheOptions: .options( + profiles: .profiles(default: Environment.isCI ? .none : .onlyExternal) ) + ) ) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 372b5dd6..606051b6 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "90e503ea5a509853c4fdee624e7bad613897a8b9e08c1c0b8513b4a6073c7ef9", + "originHash" : "ed04bf35e3364b25fd627c02cb1852f827813d3f714fe41fa18bdc43204c0bea", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -109,6 +109,15 @@ "version" : "8.1.0" } }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" + } + }, { "identity" : "grpc-binary", "kind" : "remoteSourceControl", @@ -163,15 +172,6 @@ "version" : "1.22.5" } }, - { - "identity" : "logmacro", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Roy-wonji/LogMacro.git", - "state" : { - "revision" : "593b210346cf7145074c2d94fa4206bbc70198e0", - "version" : "1.1.1" - } - }, { "identity" : "mixpanel-ios-session-replay-package", "kind" : "remoteSourceControl", @@ -244,13 +244,22 @@ "version" : "9.21.0" } }, + { + "identity" : "sqlite-data", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/sqlite-data", + "state" : { + "revision" : "a9c517261ba6e13483f80e6903c66ad8ebaa649e", + "version" : "1.11.0" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" + "revision" : "cb4cba6a8a46a86db0d37dbe09935ead6b24c91a", + "version" : "1.10.0" } }, { @@ -276,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "1eaa6fa2ee57ac42843283b9fd3457af408c858d", - "version" : "1.25.5" + "revision" : "377da4061db10d26337a71bb279c506bb951f50f", + "version" : "1.26.2" } }, { @@ -285,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "5fa253428866f2360c3754e88537f700ed2656b5", + "version" : "1.4.1" } }, { @@ -303,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies.git", "state" : { - "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", - "version" : "1.10.0" + "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", + "version" : "1.12.0" } }, { @@ -348,8 +357,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", - "version" : "1.6.0" + "revision" : "de219a1cf34e958134e75a9ebb134cf09bf52fc6", + "version" : "2.0.11" } }, { @@ -357,35 +366,44 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "55d2fef5008b054420f90d745ea0520850955462", - "version" : "1.1.3" + "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", + "version" : "2.9.1" } }, { - "identity" : "swift-syntax", + "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" + "revision" : "59a99c458de4d2dee580529b61b4f78dca7b7fa6", + "version" : "1.19.4" } }, { - "identity" : "tcaflow", + "identity" : "swift-structured-queries", "kind" : "remoteSourceControl", - "location" : "https://github.com/Roy-wonji/TCAFlow.git", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "7cdc5c4514e24b1b828fd39f5b97badb7ffeaeae", + "version" : "0.37.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "f439d1478df86d2146b3472216931b63a5a9f1fb", - "version" : "1.1.3" + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" } }, { - "identity" : "weavedi", + "identity" : "tcaflow", "kind" : "remoteSourceControl", - "location" : "https://github.com/Roy-wonji/WeaveDI.git", + "location" : "https://github.com/Roy-wonji/TCAFlow.git", "state" : { - "revision" : "41881e33a1fc2b2ade7b3c1ac927e630cd984796", - "version" : "3.4.1" + "revision" : "4748aa2f56d50b0965e50575e4e332111085a91b", + "version" : "1.1.8" } }, { @@ -393,8 +411,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", - "version" : "1.7.0" + "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", + "version" : "1.13.1" } } ], diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 8dbdeca4..d65b95f9 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -26,7 +26,6 @@ "XCTestDynamicOverlay": .framework, "Clocks": .framework, "ConcurrencyExtras": .framework, - "WeaveDI": .framework, "ReactiveSwift": .framework, "SDWebImage": .framework, "SDWebImageSwiftUI": .framework, @@ -35,6 +34,19 @@ "GoogleMobileAds": .framework, "Sentry": .framework, "SentrySwiftUI": .framework, + + // Sharing·SQLiteData 는 여러 동적 모듈이 함께 쓰므로 단일 런타임으로 공유한다. + // 버전 마커(Sharing1/2)는 정적으로 링크해 앱이 Sharing1.framework 를 찾지 않게 한다. + "Sharing1": .staticFramework, + "Sharing2": .staticFramework, + "SQLiteData": .framework, + "GRDB": .framework, + "GRDBSQLite": .framework, + "GRDB_GRDB": .framework, + "StructuredQueries": .framework, + "StructuredQueriesCore": .framework, + "StructuredQueriesSQLite": .framework, + "StructuredQueriesSQLiteCore": .framework, ], // 외부 SPM 패키지 타깃(Sentry/WebKit 등)에도 Explicitly Built Modules 비활성화. // (Xcode 26 에서 Sentry→WebKit 빌드 시 system Network 모듈의 os_object 미제공 + @@ -43,6 +55,10 @@ base: [ "SWIFT_ENABLE_EXPLICIT_MODULES": "NO", "CLANG_ENABLE_EXPLICIT_MODULES": "NO", + // Xcode 26 의 XCTest 가 먼저 로드하는 애플 private `Sharing` 모듈과 충돌해 + // xctest 부팅이 깨진다. Point-Free 구현은 다른 모듈명으로 빌드하고 + // 소스의 `import Sharing` 은 별칭으로 이어 붙인다. + "OTHER_SWIFT_FLAGS": "$(inherited) -module-alias Sharing=PickePointFreeSharing", ], // 외부 SPM 패키지도 앱과 동일한 커스텀 컨피그(Stage/Prod/Release)를 갖게 한다. // 이게 없으면 패키지는 기본 [Debug, Release]만 생성 → Stage 빌드 시 리소스 번들이 @@ -53,17 +69,23 @@ .release(name: "Prod"), .release(name: "Release"), ] - ) + ), + targetSettings: [ + // 위 module-alias 와 짝. 산출물 이름 자체를 바꿔 시스템 Sharing.framework 를 가리지 않는다. + "Sharing": .settings(base: ["PRODUCT_NAME": "PickePointFreeSharing"]), + ] ) #endif let package = Package( name: "TimeSpot", dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.25.5"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.6.0"), - .package(url: "https://github.com/Roy-wonji/TCAFlow.git", exact: "1.1.3"), - .package(url: "https://github.com/Roy-wonji/WeaveDI.git", from: "3.4.1"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.26.2"), + // 1.13+의 traits 조건부 Clocks/CombineSchedulers 의존성이 Tuist에서 누락되는 문제를 피한다. + // 소스 빌드와 바이너리 캐시가 같은 그래프를 쓰도록 마지막 비조건부 버전을 고정한다. + .package(url: "https://github.com/pointfreeco/swift-dependencies", exact: "1.12.0"), + .package(url: "https://github.com/pointfreeco/sqlite-data", exact: "1.11.0"), + .package(url: "https://github.com/Roy-wonji/TCAFlow.git", exact: "1.1.8"), .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.1.0"), .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.10.2"), .package(url: "https://github.com/openid/AppAuth-iOS.git", from: "2.0.0"), diff --git a/TuistTool.swift b/TuistTool.swift index c3faa7ef..210d48d2 100755 --- a/TuistTool.swift +++ b/TuistTool.swift @@ -1,1146 +1,716 @@ // -// tuisttool.swift +// TuistTool.swift +// Picke +// +// Created by Wonji Suh on 9/7/26. // import Foundation -// 🆕 Tuist 4.174.0+ mise를 통한 실행 헬퍼 -@discardableResult -func runTuist(arguments: [String] = []) -> Int32 { - return run("mise", arguments: ["exec", "--", "tuist"] + arguments) +private enum Command: String { + case setup + case generate + case build + case install + case cache + case cacheSetup = "cache:setup" + case test + case format + case lint + case clean + case reset + case edit + case inspect + case inspectImports = "inspect-imports" + case inspectCoverage = "inspect-coverage" + case module + case moduleInit = "moduleinit" + case feature + case core + case service + case domain + case ui + case graph + case productionGraph = "graph:prod" + case help } @discardableResult -func run(_ command: String, arguments: [String] = []) -> Int32 { +private func run( + _ executable: String, + arguments: [String], + environmentOverrides: [String: String] = [:] +) -> Int32 { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + arguments + process.arguments = [executable] + arguments + process.environment = ProcessInfo.processInfo.environment.merging(environmentOverrides) { _, new in new } + process.standardInput = FileHandle.standardInput process.standardOutput = FileHandle.standardOutput process.standardError = FileHandle.standardError - // 🔥 현재 프로세스의 환경변수를 자식 프로세스에 전달 - var environment = ProcessInfo.processInfo.environment - - // setenv로 설정된 환경변수들을 수동으로 추가 - if let projectName = getenv("PROJECT_NAME") { - environment["PROJECT_NAME"] = String(cString: projectName) - } - if let bundleId = getenv("BUNDLE_ID_PREFIX") { - environment["BUNDLE_ID_PREFIX"] = String(cString: bundleId) - } - if let teamId = getenv("TEAM_ID") { - environment["TEAM_ID"] = String(cString: teamId) - } - - process.environment = environment - do { try process.run() process.waitUntilExit() return process.terminationStatus } catch { - print("❌ 실행 실패: \(error)") - return -1 + FileHandle.standardError.write(Data("실행 실패: \(error)\n".utf8)) + return 1 } } -func runCapture(_ command: String, arguments: [String] = []) throws -> String { - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + arguments - process.standardOutput = pipe - try process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) -} - -func prompt(_ message: String) -> String { +private func prompt(_ message: String) -> String { print("\(message): ", terminator: "") - fflush(stdout) // Force flush output buffer - - guard let input = readLine() else { - return "" - } - - let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) - - // Debug output (개발시에만 활성화) - // print("🔍 Debug: 입력된 값 = '\(trimmedInput)' (길이: \(trimmedInput.count))") - - return trimmedInput + return readLine()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } -// MARK: - Tuist 명령어 (tuist 4.97.2 최적화) -func generate() { - // ✅ 루트 경로 환경 변수 설정 - setenv("TUIST_ROOT_DIR", FileManager.default.currentDirectoryPath, 1) - - // ✅ 프리뷰 모드 환경 변수 추가 - setenv("TUIST_FOR_PREVIEW", "TRUE", 1) - - // 📁 기존 hasTests: true 모듈들의 Tests/Sources 디렉토리 확인 (하위 호환성) - ensureTestsDirectoriesForHasTestsModules() - - // ✅ tuist generate 실행 (4.174.0+) - runTuist(arguments: ["generate"]) +@discardableResult +private func runTuist( + arguments: [String], + environmentOverrides: [String: String] = [:] +) -> Int32 { + return run( + "mise", + arguments: ["exec", "--", "tuist"] + arguments, + environmentOverrides: environmentOverrides + ) } -// tuist 4.97.2 새로운 기능들 -func inspect() { - print("🔍 사용 가능한 inspect 명령어들:") - run("tuist", arguments: ["inspect", "--help"]) -} +private var didPrepareLocalTuistAccess = false +private var isLocalTuistAccessUnavailable = false -func inspectImplicitImports() { - print("🔍 암시적 의존성 검사 중...") - run("tuist", arguments: ["inspect", "implicit-imports"]) +private var isCIEnvironment: Bool { + let environment = ProcessInfo.processInfo.environment + let ciValues = ["1", "true", "TRUE"] + return ciValues.contains(environment["CI"] ?? "") + || ciValues.contains(environment["GITHUB_ACTIONS"] ?? "") + || ciValues.contains(environment["BITRISE_IO"] ?? "") + || ciValues.contains(environment["TUIST_CI"] ?? "") } -func inspectCodeCoverage() { - print("📊 코드 커버리지 분석 중...") - run("tuist", arguments: ["inspect", "code-coverage"]) +private func usesBinaryCache(forwardedArguments: [String]) -> Bool { + return !forwardedArguments.contains("--no-binary-cache") } -// MARK: - 새 프로젝트 생성 -func newProject() { - print("\n🚀 새 프로젝트 생성을 시작합니다.") - - let projectName = prompt("프로젝트 이름을 입력하세요") - guard !projectName.isEmpty else { - print("❌ 프로젝트 이름은 필수입니다.") - return - } - - let bundleIdPrefix = prompt("번들 ID 접두사를 입력하세요 (기본값: io.Roy.Module)") - let finalBundleId = bundleIdPrefix.isEmpty ? "io.Roy.Module" : bundleIdPrefix - - let teamId = prompt("팀 ID를 입력하세요 (기본값: N94CS4N6VR)") - let finalTeamId = teamId.isEmpty ? "N94CS4N6VR" : teamId - - print("\n📋 설정 정보:") - print("📱 프로젝트명: \(projectName)") - print("📦 번들 ID 접두사: \(finalBundleId)") - print("👥 팀 ID: \(finalTeamId)") - - let confirm = prompt("\n위 설정으로 프로젝트를 생성하시겠습니까? (y/N)") - guard confirm.lowercased() == "y" else { - print("❌ 프로젝트 생성이 취소되었습니다.") - return - } - - generateProjectWithSettings( - name: projectName, - bundleIdPrefix: finalBundleId, - teamId: finalTeamId - ) +private func warnAndContinue(_ message: String) { + FileHandle.standardError.write(Data("⚠️ \(message)\n".utf8)) } -func generateProjectWithArgs() { - let args = Array(CommandLine.arguments.dropFirst(2)) // command와 하위 명령 제외 - - guard args.count >= 1 else { - print("사용법: ./tuisttool generate --name <프로젝트명> [--bundle-id <번들ID>] [--team-id <팀ID>]") - return - } - - var projectName = "" - var bundleIdPrefix = "io.Roy.Module" - var teamId = "N94CS4N6VR" - - var i = 0 - while i < args.count { - switch args[i] { - case "--name", "-n": - if i + 1 < args.count { - projectName = args[i + 1] - i += 1 - } - case "--bundle-id", "-b": - if i + 1 < args.count { - bundleIdPrefix = args[i + 1] - i += 1 - } - case "--team-id", "-t": - if i + 1 < args.count { - teamId = args[i + 1] - i += 1 - } - default: - if projectName.isEmpty { - projectName = args[i] - } - } - i += 1 +private func prepareLocalTuistAccess(allowFailure: Bool) -> Int32 { + guard !isCIEnvironment else { + print("CI 환경이라 Tuist Dashboard 인증과 프로젝트 확인을 건너뜁니다.") + return 0 + } + guard !didPrepareLocalTuistAccess else { return 0 } + + let whoamiStatus = runTuist(arguments: ["auth", "whoami"]) + if whoamiStatus != 0 { + let loginStatus = runTuist(arguments: ["auth", "login"]) + guard loginStatus == 0 else { + if allowFailure { + isLocalTuistAccessUnavailable = true + warnAndContinue("Tuist 인증에 실패했습니다. 바이너리 캐시 없이 계속 진행합니다.") + return 0 + } + return loginStatus } + } - guard !projectName.isEmpty else { - print("❌ 프로젝트 이름은 필수입니다.") - print("사용법: ./tuisttool newproject <프로젝트명> [--bundle-id <번들ID>] [--team-id <팀ID>]") - return + let projectStatus = runTuist(arguments: ["project", "show"]) + guard projectStatus == 0 else { + if allowFailure { + isLocalTuistAccessUnavailable = true + warnAndContinue("Tuist Dashboard 프로젝트 확인에 실패했습니다. 바이너리 캐시 없이 계속 진행합니다.") + return 0 } + return projectStatus + } - generateProjectWithSettings( - name: projectName, - bundleIdPrefix: bundleIdPrefix, - teamId: teamId - ) + didPrepareLocalTuistAccess = true + isLocalTuistAccessUnavailable = false + return 0 } -func generateProjectWithSettings(name: String, bundleIdPrefix: String, teamId: String) { - print("\n⚙️ 환경변수 설정 중...") - setenv("PROJECT_NAME", name, 1) - setenv("BUNDLE_ID_PREFIX", bundleIdPrefix, 1) - setenv("TEAM_ID", teamId, 1) - - // 🚨 중요: tuist generate 전에 필수 디렉토리들 미리 생성 - print("📁 필수 디렉토리 사전 생성 중...") - - // 1. 기본 테스트 디렉토리 생성 (템플릿에 필요) - ensureDirectoryExists(at: "Projects/App/Tests") - ensureDirectoryExists(at: "Projects/App/Tests/Sources") - - // 2. FontAsset 디렉토리 생성 (경고 해결) - ensureDirectoryExists(at: "Projects/Shared/DesignSystem/FontAsset") - - print("📁 디렉토리 생성 완료:") - print(" - Tests: \(FileManager.default.fileExists(atPath: "Projects/App/Tests") ? "✅" : "❌")") - print(" - FontAsset: \(FileManager.default.fileExists(atPath: "Projects/Shared/DesignSystem/FontAsset") ? "✅" : "❌")") - - // 기본 테스트 파일 생성 (없으면) - let originalTestFilePath = "Projects/App/Tests/Sources/\(name)Tests.swift" - if !FileManager.default.fileExists(atPath: originalTestFilePath) { - let testFileContent = """ - // - // \(name)Tests.swift - // \(name)Tests - // - // Created by TuistTool. - // - - import XCTest - - final class \(name)Tests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. - } - - override func tearDownWithError() throws { - // Put teardown code here. - } - - func testExample() throws { - // This is an example of a functional test case. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - - } - """ - - do { - try testFileContent.write(toFile: originalTestFilePath, atomically: true, encoding: .utf8) - print("✅ 기본 테스트 파일 생성: \(originalTestFilePath)") - } catch { - print("⚠️ 기본 테스트 파일 생성 실패: \(error)") - } - } - - print("🧹 기존 프로젝트 정리 중...") - _ = run("tuist", arguments: ["clean"]) - - // 기존 워크스페이스 파일들 삭제 - let filesToRemove = [ - "MultiModuleTemplate.xcworkspace", - "\(name).xcworkspace" // 혹시 이미 있을 수도 있으니 - ] - - for file in filesToRemove { - if FileManager.default.fileExists(atPath: file) { - do { - try FileManager.default.removeItem(atPath: file) - print("🗑️ 기존 워크스페이스 삭제: \(file)") - } catch { - print("⚠️ 워크스페이스 삭제 실패 (\(file)): \(error)") - } - } - } - - print("🔧 Tuist dependencies 설치 중...") - let installResult = run("tuist", arguments: ["install"]) - if installResult != 0 { - print("❌ Dependencies 설치에 실패했습니다.") - return - } - - // 🚨 중요: tuist generate 전에 이름 변경 수행! - prepareTemplateForNewProject(oldName: "MultiModuleTemplate", newName: name, bundleIdPrefix: bundleIdPrefix, teamId: teamId) - - // 💯 이름 변경 완료 후 최종 검증 - print("🔍 이름 변경 최종 검증 중...") - let projectConfigPath = "Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift" - if let content = try? String(contentsOfFile: projectConfigPath, encoding: .utf8) { - if content.contains("projectName: String = \"\(name)\"") { - print("✅ 최종 검증 성공: ProjectConfig.swift에서 \(name) 확인됨") - } else { - print("❌ 최종 검증 실패: ProjectConfig.swift에서 \(name)을 찾을 수 없음") - print(" 현재 프로젝트명 라인:") - let lines = content.components(separatedBy: .newlines) - for (i, line) in lines.enumerated() { - if line.contains("projectName") { - print(" 라인 \(i+1): \(line)") - } - } - print("❌ 프로젝트 생성을 중단합니다.") - return - } - } - - print("🔧 Tuist 프로젝트 생성 중...") - let result = run("tuist", arguments: ["generate"]) - - if result == 0 { - print("✅ Tuist 프로젝트 생성 성공!") - - // 생성된 워크스페이스 확인 및 이름 변경 - let expectedWorkspaceName = "\(name).xcworkspace" - let oldWorkspaceName = "MultiModuleTemplate.xcworkspace" - - print("🔍 생성된 워크스페이스 확인 중...") - - // 새 이름으로 이미 생성되었는지 확인 - if FileManager.default.fileExists(atPath: expectedWorkspaceName) { - print("✅ 올바른 이름의 워크스페이스 생성됨: \(expectedWorkspaceName)") - } - // 아직 옛날 이름으로 생성되었다면 이름 변경 - else if FileManager.default.fileExists(atPath: oldWorkspaceName) { - do { - try FileManager.default.moveItem(atPath: oldWorkspaceName, toPath: expectedWorkspaceName) - print("📝 Workspace 이름 변경: \(oldWorkspaceName) → \(expectedWorkspaceName)") - } catch { - print("⚠️ Workspace 이름 변경 실패: \(error)") - } - } - else { - print("⚠️ 예상된 워크스페이스 파일을 찾을 수 없습니다") - // 현재 디렉토리의 .xcworkspace 파일들 확인 - if let files = try? FileManager.default.contentsOfDirectory(atPath: ".") { - let workspaceFiles = files.filter { $0.hasSuffix(".xcworkspace") } - print(" 현재 디렉토리의 워크스페이스 파일들: \(workspaceFiles)") - } - } - - // renameProjectArtifacts는 이미 prepareTemplateForNewProject에서 호출됨 - - print("\n✅ 프로젝트 '\(name)'이 성공적으로 생성되었습니다!") - print("💡 다음 명령어로 Xcode에서 열 수 있습니다:") - print(" open \(expectedWorkspaceName)") - } else { - print("❌ 프로젝트 생성에 실패했습니다.") - } +private func prepareBinaryCacheIfNeeded( + forwardedArguments: [String], + allowFailure: Bool +) -> Int32 { + guard usesBinaryCache(forwardedArguments: forwardedArguments) else { + print("--no-binary-cache 옵션이 있어 Tuist Dashboard 인증과 캐시 준비를 건너뜁니다.") + return 0 + } + return prepareLocalTuistAccess(allowFailure: allowFailure) } -private func prepareTemplateForNewProject(oldName: String, newName: String, bundleIdPrefix: String, teamId: String) { - print("🔄 템플릿 준비 중...") - print(" - 이전 이름: \(oldName)") - print(" - 새 이름: \(newName)") - print(" - 번들 ID: \(bundleIdPrefix)") - print(" - 팀 ID: \(teamId)") - - // 1단계: 프로젝트 아티팩트 이름 변경 - renameProjectArtifacts(oldName: oldName, newName: newName) - - // 2단계: 환경 설정 파일 업데이트 - updateEnvironmentDefaults(oldName: oldName, newName: newName, bundleIdPrefix: bundleIdPrefix, teamId: teamId) +private func cacheWarmArguments(forwardedArguments: [String]) -> [String] { + if forwardedArguments.first == "print-hashes" { + return ["cache"] + forwardedArguments + } - // 3단계: ProjectConfig.swift 업데이트 (핵심!) - updateProjectConfig(newName: newName, bundleIdPrefix: bundleIdPrefix, teamId: teamId) + var warmArguments = forwardedArguments + if warmArguments.first == "warm" { + warmArguments.removeFirst() + } - // 4단계: xconfig 파일들 업데이트 - updateXConfigFiles(newName: newName) + var arguments = ["cache", "warm"] + if !warmArguments.contains("--external-only"), !warmArguments.contains("--no-external-only") { + arguments.append("--external-only") + } + return arguments + warmArguments +} - // 5단계: 검증 - verifyNameChange(oldName: oldName, newName: newName) +private func installArguments(forwardedArguments: [String]) -> [String] { + return forwardedArguments.filter { argument in + argument != "--no-binary-cache" && argument != "--no-open" + } } -private func renameProjectArtifacts(oldName: String, newName: String) { - guard oldName != newName else { return } - - let appRoot = "Projects/App" - - let oldProjectPath = "\(appRoot)/\(oldName).xcodeproj" - let newProjectPath = "\(appRoot)/\(newName).xcodeproj" - renameItemIfNeeded(at: oldProjectPath, to: newProjectPath, description: ".xcodeproj 이동") - - updateXcodeProjectContent(at: newProjectPath, oldName: oldName, newName: newName) - - let oldTestsFolder = "\(appRoot)/\(oldName)Tests" - let newTestsFolder = "\(appRoot)/\(newName)Tests" - renameItemIfNeeded(at: oldTestsFolder, to: newTestsFolder, description: "테스트 타겟 폴더 이동") - - // 테스트 디렉토리 강제 생성 (더 확실하게) - ensureDirectoryExists(at: newTestsFolder) - ensureDirectoryExists(at: "\(newTestsFolder)/Sources") - - print("📁 테스트 디렉토리 확인:") - print(" - \(newTestsFolder): \(FileManager.default.fileExists(atPath: newTestsFolder) ? "✅" : "❌")") - print(" - \(newTestsFolder)/Sources: \(FileManager.default.fileExists(atPath: "\(newTestsFolder)/Sources") ? "✅" : "❌")") - - let oldTestFile = "\(newTestsFolder)/Sources/\(oldName)Tests.swift" - let newTestFile = "\(newTestsFolder)/Sources/\(newName)Tests.swift" - renameItemIfNeeded(at: oldTestFile, to: newTestFile, description: "테스트 파일 이름 변경") - replaceOccurrences(inFileAtPath: newTestFile, replacements: [oldName: newName, "\(oldName)Tests": "\(newName)Tests"]) - - let applicationSourcesPath = "\(appRoot)/Sources/Application" - let oldAppFile = "\(applicationSourcesPath)/\(oldName)App.swift" - let newAppFile = "\(applicationSourcesPath)/\(newName)App.swift" - renameItemIfNeeded(at: oldAppFile, to: newAppFile, description: "App Entry 파일 이름 변경") - replaceOccurrences( - inFileAtPath: newAppFile, - replacements: [ - "\(oldName)App": "\(newName)App", - "TuistAssets+\(oldName)": "TuistAssets+\(newName)", - "TuistBundle+\(oldName)": "TuistBundle+\(newName)" - ] - ) +private func filteredGenerateArguments(forwardedArguments: [String]) -> [String] { + return forwardedArguments.filter { argument in + argument == "--no-binary-cache" || argument == "--no-open" + } } -private func renameItemIfNeeded(at oldPath: String, to newPath: String, description: String) { - let fileManager = FileManager.default - guard oldPath != newPath else { return } - guard fileManager.fileExists(atPath: oldPath) else { return } - - do { - if fileManager.fileExists(atPath: newPath) { - try fileManager.removeItem(atPath: newPath) - } - try fileManager.moveItem(atPath: oldPath, toPath: newPath) - } catch { - print("⚠️ \(description) 실패: \(error)") - } +private func generateArguments(forwardedArguments: [String]) -> [String] { + guard isLocalTuistAccessUnavailable, usesBinaryCache(forwardedArguments: forwardedArguments) + else { + return forwardedArguments + } + return forwardedArguments + ["--no-binary-cache"] } -private func ensureDirectoryExists(at path: String) { - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: path) { - do { - try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) - } catch { - print("⚠️ 디렉토리 생성 실패 (\(path)): \(error)") - } +private func warmBinaryCache(forwardedArguments: [String] = []) -> Int32 { + guard !isCIEnvironment else { + print("CI 환경이라 Tuist 바이너리 캐시 준비를 건너뜁니다.") + return 0 + } + guard usesBinaryCache(forwardedArguments: forwardedArguments) else { + print("--no-binary-cache 옵션이 있어 Tuist 바이너리 캐시 준비를 건너뜁니다.") + return 0 + } + let authStatus = prepareLocalTuistAccess(allowFailure: false) + guard authStatus == 0 else { return authStatus } + return runTuist( + arguments: cacheWarmArguments(forwardedArguments: forwardedArguments), + environmentOverrides: ["TUIST_LOCAL_CACHE_ONLY": "true"] + ) +} + +private func installAndGenerate(forwardedArguments: [String] = []) -> Int32 { + let authStatus = prepareBinaryCacheIfNeeded( + forwardedArguments: forwardedArguments, + allowFailure: true + ) + guard authStatus == 0 else { return authStatus } + let installStatus = runTuist(arguments: ["install"] + installArguments(forwardedArguments: forwardedArguments)) + guard installStatus == 0 else { return installStatus } + let generateForwardedArguments = filteredGenerateArguments(forwardedArguments: forwardedArguments) + return runTuist(arguments: ["generate"] + generateArguments(forwardedArguments: generateForwardedArguments)) +} + +private enum StepResult { + case passed + case skipped(String) + case failed(Int32) +} + +private func printSetupSummary(_ results: [(String, StepResult)]) { + print("") + print("📋 setup 결과") + for (name, result) in results { + switch result { + case .passed: + print(" ✅ \(name)") + case let .skipped(reason): + print(" ⚠️ \(name) — 건너뜀 (\(reason))") + case let .failed(status): + print(" ❌ \(name) — 실패 (exit \(status))") } + } } -// MARK: - Tests 디렉토리 자동 생성 -private func ensureTestsDirectoriesForHasTestsModules() { - print("🔍 hasTests: true인 모듈들의 Tests/Sources 디렉토리 확인 중...") +private func resetProject() -> Int32 { + let fileManager = FileManager.default + let derivedDataURL = fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Developer/Xcode/DerivedData", isDirectory: true) - let fileManager = FileManager.default - guard let enumerator = fileManager.enumerator(atPath: "Projects") else { - print("⚠️ Projects 디렉토리를 찾을 수 없습니다") - return + do { + let entries = try fileManager.contentsOfDirectory( + at: derivedDataURL, + includingPropertiesForKeys: nil + ) + for entry in entries where entry.lastPathComponent.hasPrefix("Picke-") { + try fileManager.removeItem(at: entry) + print("DerivedData 삭제: \(entry.lastPathComponent)") } + } catch CocoaError.fileReadNoSuchFile { + // DerivedData가 아직 없다면 정리할 것도 없으므로 다음 단계로 진행합니다. + } catch { + FileHandle.standardError.write(Data("DerivedData 정리 실패: \(error)\n".utf8)) + return 1 + } - var createdCount = 0 - var existingCount = 0 - - while let relativePath = enumerator.nextObject() as? String { - guard relativePath.hasSuffix("Project.swift") else { continue } - - let fullPath = "Projects/\(relativePath)" - let projectDir = URL(fileURLWithPath: fullPath).deletingLastPathComponent().path - - // Project.swift 파일에서 hasTests: true 확인 - do { - let content = try String(contentsOfFile: fullPath, encoding: .utf8) - if content.contains("hasTests: true") { - let testsSourcesPath = "\(projectDir)/Tests/Sources" - - if !fileManager.fileExists(atPath: testsSourcesPath) { - ensureDirectoryExists(at: testsSourcesPath) - print("📁 Created Tests/Sources for \(URL(fileURLWithPath: projectDir).lastPathComponent)") - createdCount += 1 - } else { - existingCount += 1 - } - } - } catch { - print("⚠️ \(fullPath) 파일 읽기 실패: \(error)") - } + let cleanStatus = runTuist(arguments: ["clean"]) + guard cleanStatus == 0 else { return cleanStatus } + return installAndGenerate() +} + +/// 레이어마다 scaffold 대상 디렉터리, 카탈로그 enum, 의존성 헬퍼, 엄브렐러가 다르다. +/// 이 대응표를 한곳에 두어야 모듈 추가가 손으로 세 파일을 고치는 일이 되지 않는다. +private enum ModuleLayer: String, CaseIterable { + case feature = "Feature" + case core = "Core" + case service = "Service" + case domain = "Domain" + case ui = "UI" + + /// Modules.swift 안의 카탈로그 enum 이름. + var catalogEnumName: String { + switch self { + case .feature: return "FeatureModule" + case .core: return "CoreModule" + case .service: return "ServiceModule" + case .domain: return "DomainModule" + case .ui: return "UIModule" } + } - if createdCount > 0 { - print("✅ \(createdCount)개의 Tests/Sources 디렉토리가 생성되었습니다") + /// `.feature(.splash)` 에서 `feature` 에 해당하는 TargetDependency 헬퍼 이름. + var dependencyHelperName: String { + switch self { + case .feature: return "feature" + case .core: return "core" + case .service: return "service" + case .domain: return "domain" + case .ui: return "ui" } - if existingCount > 0 { - print("ℹ️ \(existingCount)개의 Tests/Sources 디렉토리가 이미 존재합니다") - } - if createdCount == 0 && existingCount == 0 { - print("ℹ️ hasTests: true인 모듈을 찾을 수 없습니다") - } -} - -private func updateEnvironmentDefaults(oldName: String, newName: String, bundleIdPrefix: String, teamId: String) { - let environmentPath = "Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Enviorment.swift" - - print("🔧 Project+Environment.swift 업데이트 중...") + } - guard FileManager.default.fileExists(atPath: environmentPath) else { - print("⚠️ Environment 파일을 찾을 수 없습니다: \(environmentPath)") - return + /// 새 모듈을 자동으로 물릴 엄브렐러. UI 레이어는 엄브렐러가 없다. + var umbrellaManifestPath: String? { + switch self { + case .feature: return "Projects/Feature/FeatureAssembly/Project.swift" + case .core: return "Projects/Core/CoreAssembly/Project.swift" + case .service: return "Projects/Service/ServiceAssembly/Project.swift" + case .domain: return "Projects/Domain/DomainAssembly/Project.swift" + case .ui: return nil } + } - do { - var content = try String(contentsOfFile: environmentPath, encoding: .utf8) - let originalContent = content - - // ProjectConfig.projectName 참조로 변경 (하드코딩 제거) - let projectNamePattern = #"return \"[^\"]+\""# - let projectNameReplacement = "return ProjectConfig.projectName" - content = content.replacingOccurrences(of: projectNamePattern, with: projectNameReplacement, options: .regularExpression) - - // 기존 하드코딩된 값들 업데이트 (백업용) - content = content.replacingOccurrences(of: #"BUNDLE_ID_PREFIX"] ?? \"[^\"]+\""#, with: "BUNDLE_ID_PREFIX\"] ?? \"\(bundleIdPrefix)\"", options: .regularExpression) - content = content.replacingOccurrences(of: #"TEAM_ID"] ?? \"[^\"]+\""#, with: "TEAM_ID\"] ?? \"\(teamId)\"", options: .regularExpression) - - // 이전 이름을 새 이름으로 바꾸기 - content = content.replacingOccurrences(of: oldName, with: newName) - - if content != originalContent { - try content.write(toFile: environmentPath, atomically: true, encoding: .utf8) - print("✅ Project+Environment.swift 업데이트 완료") - } else { - print("ℹ️ Project+Environment.swift 변경사항 없음") - } + /// 카탈로그 case 는 레이어 이름을 되풀이하지 않는다 — `AuthDomain` 은 `auth` 로 적힌다. + var redundantNameSuffix: String? { + switch self { + case .domain: return "Domain" + case .service: return "Service" + case .feature, .core, .ui: return nil + } + } - } catch { - print("❌ Environment 파일 업데이트 실패: \(error)") + init?(argument: String) { + let normalized = argument.lowercased() + guard let matched = ModuleLayer.allCases.first(where: { $0.rawValue.lowercased() == normalized }) + else { + return nil } + self = matched + } } -private func updateXcodeProjectContent(at projectPath: String, oldName: String, newName: String) { - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: projectPath) else { return } - - let pbxprojPath = "\(projectPath)/project.pbxproj" - replaceOccurrences( - inFileAtPath: pbxprojPath, - replacements: [ - "\(oldName)": "\(newName)", - "\(oldName)Tests": "\(newName)Tests" - ] - ) - - let schemesDirectory = "\(projectPath)/xcshareddata/xcschemes" - guard let schemes = try? fileManager.contentsOfDirectory(atPath: schemesDirectory) else { return } +private let moduleCatalogPath = + "Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift" - for scheme in schemes where scheme.contains(oldName) { - let oldSchemePath = "\(schemesDirectory)/\(scheme)" - let newSchemeName = scheme.replacingOccurrences(of: oldName, with: newName) - let newSchemePath = "\(schemesDirectory)/\(newSchemeName)" - renameItemIfNeeded(at: oldSchemePath, to: newSchemePath, description: "스킴 파일 이름 변경") - replaceOccurrences(inFileAtPath: newSchemePath, replacements: [oldName: newName]) - } -} +/// Module 템플릿이 author 를 required 로 받지만 파일 헤더는 저장소 전체가 같은 이름으로 통일돼 있다. +/// 명령 인자로 열어두면 헤더만 어긋나므로 고정값으로 넘긴다. +private let scaffoldAuthor = "Wonji Suh" -private func replaceOccurrences(inFileAtPath path: String, replacements: [String: String]) { - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: path) else { return } - - do { - var content = try String(contentsOfFile: path, encoding: .utf8) - var updated = false - for (target, replacement) in replacements { - if content.contains(target) { - content = content.replacingOccurrences(of: target, with: replacement) - updated = true - } - } - - if updated { - try content.write(toFile: path, atomically: true, encoding: .utf8) - } - } catch { - print("⚠️ 문자열 치환 실패 (\(path)): \(error)") - } -} +/// `PickeNetwork` → `network`, `AuthDomain` → `auth`, `Splash` → `splash`. +/// 카탈로그에는 `apiEndpoint = "APIEndpoint"` 처럼 두문자어라 규칙으로 못 맞추는 case 도 있어서 +/// 어긋나는 이름은 `--case` 로 직접 지정한다. +private func defaultCaseName(for moduleName: String, layer: ModuleLayer) -> String { + var name = moduleName + if name.hasPrefix("Picke"), name.count > 5 { + name.removeFirst(5) + } + if let suffix = layer.redundantNameSuffix, name.hasSuffix(suffix), name.count > suffix.count { + name.removeLast(suffix.count) + } + guard let first = name.first else { return name } + return first.lowercased() + name.dropFirst() +} + +/// 내부 모듈 그래프를 만든다. +/// +/// 기본 그래프는 Tests·Testing·Interface 까지 포함해 각 모듈의 진입점을 함께 보여주되 +/// Demo 앱은 제외한다. 배포 구조만 확인하는 `graph:prod`에서는 Tests까지 함께 제외한다. +/// Tuist에는 Demo 타깃만 제외하는 옵션이 없어 dot에서 Demo 노드와 간선을 걷어낸다. +private func renderGraph( + excludesDemo: Bool, + extraTuistArguments: [String], + forwardedArguments: [String] +) -> Int32 { + let fileManager = FileManager.default + let workDirectory = fileManager.temporaryDirectory + .appendingPathComponent("picke-graph-\(UUID().uuidString)") -private func replacePattern(inFileAtPath path: String, pattern: String, replacement: String) { - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: path) else { return } - - do { - let content = try String(contentsOfFile: path, encoding: .utf8) - let regex = try NSRegularExpression(pattern: pattern, options: []) - let range = NSRange(location: 0, length: (content as NSString).length) - let template = NSRegularExpression.escapedTemplate(for: replacement) - let newContent = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: template) - if newContent != content { - try newContent.write(toFile: path, atomically: true, encoding: .utf8) - } - } catch { - print("⚠️ 문자열 패턴 치환 실패 (\(path)): \(error)") - } -} + do { + try fileManager.createDirectory(at: workDirectory, withIntermediateDirectories: true) + } catch { + FileHandle.standardError.write(Data("작업 디렉터리를 만들지 못했습니다: \(error)\n".utf8)) + return 1 + } + defer { try? fileManager.removeItem(at: workDirectory) } + + let dotStatus = runTuist(arguments: [ + "graph", + "--no-open", + "--skip-external-dependencies", + "--format", "dot", + "--output-path", workDirectory.path, + ] + extraTuistArguments + forwardedArguments) + guard dotStatus == 0 else { return dotStatus } + + let dotURL = workDirectory.appendingPathComponent("graph.dot") + guard let rawGraph = try? String(contentsOf: dotURL, encoding: .utf8) else { + FileHandle.standardError.write(Data("dot 파일을 읽지 못했습니다: \(dotURL.path)\n".utf8)) + return 1 + } -// MARK: - 핵심 ProjectConfig.swift 업데이트 함수 (강화 버전) -private func updateProjectConfig(newName: String, bundleIdPrefix: String, teamId: String) { - let projectConfigPath = "Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift" + let graph: String = if excludesDemo { + // 노드 선언(`AuthDemo [..]`)과 엣지(`AuthDemo -> Auth`) 양쪽에서 + // 이름이 Demo 로 끝나는 줄을 지운다. dot 출력은 식별자에 따옴표를 붙이지 않는다. + rawGraph + .split(separator: "\n", omittingEmptySubsequences: false) + .filter { line in + line.range(of: #"\b[A-Za-z0-9_]+Demo\b"#, options: .regularExpression) == nil + } + .joined(separator: "\n") + } else { + rawGraph + } - print("🔧 ProjectConfig.swift 업데이트 중...") - print(" - 새 이름: \(newName)") - print(" - 파일 경로: \(projectConfigPath)") + let renderedGraphURL = workDirectory.appendingPathComponent("rendered-graph.dot") + do { + try graph.write(to: renderedGraphURL, atomically: true, encoding: .utf8) + } catch { + FileHandle.standardError.write(Data("렌더링할 dot 을 쓰지 못했습니다: \(error)\n".utf8)) + return 1 + } - guard FileManager.default.fileExists(atPath: projectConfigPath) else { - print("❌ ProjectConfig.swift 파일을 찾을 수 없습니다: \(projectConfigPath)") - return - } + let renderStatus = run("dot", arguments: ["-Tpng", renderedGraphURL.path, "-o", "graph.png"]) + guard renderStatus == 0 else { + FileHandle.standardError.write(Data("graphviz 렌더링에 실패했습니다. `brew install graphviz` 가 필요합니다.\n".utf8)) + return renderStatus + } - do { - var content = try String(contentsOfFile: projectConfigPath, encoding: .utf8) - let originalContent = content - print("📄 원본 파일 크기: \(content.count) 문자") - - // 1. 더 강력한 프로젝트 이름 업데이트 (여러 패턴 시도) - let patterns = [ - (#"public static let projectName: String = "[^"]*""#, "public static let projectName: String = \"\(newName)\""), - (#"projectName: String = "[^"]*""#, "projectName: String = \"\(newName)\""), - (#"let projectName: String = "[^"]*""#, "let projectName: String = \"\(newName)\""), - (#"= "MultiModuleTemplate""#, "= \"\(newName)\"") // 직접 매칭 - ] - - var updateCount = 0 - for (pattern, replacement) in patterns { - let beforeUpdate = content - content = content.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) - if content != beforeUpdate { - updateCount += 1 - print("✅ 패턴 매칭 성공: \(pattern)") - } - } - - // 2. 번들 ID 접두사 업데이트 - let bundleIdPatterns = [ - (#"public static let bundleIdPrefix = "[^"]*""#, "public static let bundleIdPrefix = \"\(bundleIdPrefix)\""), - (#"bundleIdPrefix = "[^"]*""#, "bundleIdPrefix = \"\(bundleIdPrefix)\"") - ] - - for (pattern, replacement) in bundleIdPatterns { - let beforeUpdate = content - content = content.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) - if content != beforeUpdate { - updateCount += 1 - print("✅ 번들 ID 업데이트 성공") - } - } - - // 3. 팀 ID 업데이트 - let teamIdPatterns = [ - (#"public static let teamId = "[^"]*""#, "public static let teamId = \"\(teamId)\""), - (#"teamId = "[^"]*""#, "teamId = \"\(teamId)\"") - ] - - for (pattern, replacement) in teamIdPatterns { - let beforeUpdate = content - content = content.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) - if content != beforeUpdate { - updateCount += 1 - print("✅ 팀 ID 업데이트 성공") - } - } - - if content != originalContent { - try content.write(toFile: projectConfigPath, atomically: true, encoding: .utf8) - print("✅ ProjectConfig.swift 업데이트 완료 (총 \(updateCount)개 변경)") - - // 변경 내용 검증 - let verifyContent = try String(contentsOfFile: projectConfigPath, encoding: .utf8) - if verifyContent.contains("projectName: String = \"\(newName)\"") { - print("✅ 이름 변경 검증 성공: \(newName)") - } else { - print("⚠️ 이름 변경 검증 실패!") - print(" 현재 내용에서 projectName 라인:") - let lines = verifyContent.components(separatedBy: .newlines) - for (i, line) in lines.enumerated() { - if line.contains("projectName") { - print(" 라인 \(i+1): \(line)") - } - } - } - } else { - print("⚠️ ProjectConfig.swift 변경사항 없음 - 패턴이 매칭되지 않았습니다") - // 디버깅을 위해 현재 내용 출력 - let lines = content.components(separatedBy: .newlines) - for (i, line) in lines.enumerated() { - if line.contains("projectName") { - print(" 기존 라인 \(i+1): \(line)") - } - } - } - - } catch { - print("❌ ProjectConfig.swift 업데이트 실패: \(error)") - } + let skipsTests = extraTuistArguments.contains("--skip-test-targets") + print(skipsTests + ? "graph.png 를 만들었습니다 (Tests·Demo 제외)" + : "graph.png 를 만들었습니다 (Demo 제외, Tests·Testing·Interface 포함)") + return 0 } -// MARK: - 이름 변경 검증 함수 -private func verifyNameChange(oldName: String, newName: String) { - print("🔍 이름 변경 검증 중...") - - let projectConfigPath = "Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/ProjectConfig.swift" +/// `anchor` 가 들어간 첫 줄 바로 아래에 `line` 을 끼워 넣는다. +/// `guardText` 가 이미 파일에 있으면 재실행해도 중복으로 쌓이지 않는다. +@discardableResult +private func insertLine( + _ line: String, + afterLineContaining anchor: String, + skipIfContains guardText: String, + inFileAt path: String +) -> Bool { + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + FileHandle.standardError.write(Data("파일을 읽지 못했습니다: \(path)\n".utf8)) + return false + } - if let content = try? String(contentsOfFile: projectConfigPath, encoding: .utf8) { - if content.contains("projectName: String = \"\(newName)\"") { - print("✅ ProjectConfig.swift 이름 변경 확인됨") - } else { - print("⚠️ ProjectConfig.swift에서 새 이름을 찾을 수 없습니다") - print(" 파일 내용 확인이 필요합니다") - } - } + if contents.contains(guardText) { + print("이미 등록되어 있어 건너뜁니다: \(guardText)") + return true + } - // Workspace.swift와 Project+Environment.swift 검증 - let workspacePath = "WorkSpace.swift" - let environmentPath = "Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Enviorment.swift" - - for path in [workspacePath, environmentPath] { - if FileManager.default.fileExists(atPath: path) { - if let content = try? String(contentsOfFile: path, encoding: .utf8) { - if content.contains(oldName) && oldName != newName { - print("⚠️ \(path)에 이전 이름(\(oldName))이 남아있습니다") - } else { - print("✅ \(path) 검증 통과") - } - } - } - } -} + var lines = contents.components(separatedBy: "\n") + guard let anchorIndex = lines.firstIndex(where: { $0.contains(anchor) }) else { + FileHandle.standardError.write(Data("기준 위치를 찾지 못했습니다: \(anchor) (\(path))\n".utf8)) + return false + } -func fetch() { run("tuist", arguments: ["fetch"]) } -func build() { clean(); install(); generate() } // fetch -> install로 변경 (tuist 4.97.2) -func edit() { run("tuist", arguments: ["edit"]) } -func clean() { run("tuist", arguments: ["clean"]) } -func install() { run("tuist", arguments: ["install"]) } // 새로운 install 명령어 사용 -func cache() { - print("🚀 바이너리 캐시 생성 중...") - run("tuist", arguments: ["cache"]) // 프로젝트명 제거하고 일반화 -} -func reset() { - print("🧹 캐시 및 로컬 빌드 정리 중...") - run("rm", arguments: ["-rf", "\(NSHomeDirectory())/Library/Caches/Tuist"]) - run("rm", arguments: ["-rf", "\(NSHomeDirectory())/Library/Developer/Xcode/DerivedData"]) - run("rm", arguments: ["-rf", ".tuist", ".build"]) - run("rm", arguments: ["-rf", "Tuist/Dependencies"]) // 새로운 의존성 디렉토리도 정리 - install(); generate() // fetch -> install로 변경 -} + lines.insert(line, at: lines.index(after: anchorIndex)) -// MARK: - Parsers (Modules.swift / SPM 목록에서 자동 파싱) -func availableModuleTypes() -> [String] { - let filePath = "Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift" - guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { return [] } - let pattern = "enum (\\w+):" - let regex = try? NSRegularExpression(pattern: pattern) - let matches = regex?.matches(in: content, range: NSRange(content.startIndex..., in: content)) ?? [] - return matches.compactMap { - guard let range = Range($0.range(at: 1), in: content) else { return nil } - let name = String(content[range]) - return name.hasSuffix("s") ? String(name.dropLast()) : name + do { + try lines.joined(separator: "\n").write(toFile: path, atomically: true, encoding: .utf8) + } catch { + FileHandle.standardError.write(Data("파일을 쓰지 못했습니다: \(path) - \(error)\n".utf8)) + return false } + + print("등록: \(line.trimmingCharacters(in: .whitespaces)) → \(path)") + return true } -func parseModulesFromFile(keyword: String) -> [String] { - let filePath = "Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift" - guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { - print("❗️ Modules.swift 파일을 읽을 수 없습니다.") - return [] - } - let pattern = "enum \(keyword).*?\\{([\\s\\S]*?)\\}" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), - let innerRange = Range(match.range(at: 1), in: content) else { - return [] - } - let innerContent = content[innerRange] - let casePattern = "case (\\w+)" - let caseRegex = try? NSRegularExpression(pattern: casePattern) - let lines = innerContent.components(separatedBy: .newlines) - return lines.compactMap { line in - guard let match = caseRegex?.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)), - let range = Range(match.range(at: 1), in: line) else { return nil } - return String(line[range]) +private func scaffoldModule(layer presetLayer: ModuleLayer?, arguments: [String]) -> Int32 { + var positional: [String] = [] + var caseNameOverride: String? + var index = arguments.startIndex + while index < arguments.endIndex { + if arguments[index] == "--case", arguments.index(after: index) < arguments.endIndex { + caseNameOverride = arguments[arguments.index(after: index)] + index = arguments.index(index, offsetBy: 2) + } else { + positional.append(arguments[index]) + index = arguments.index(after: index) + } } -} -func parseSPMLibraries() -> [String] { - let filePath = "Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift" - guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { - print("❗️ SPM 목록 파일을 읽을 수 없습니다.") - return [] + let layer: ModuleLayer + if let presetLayer { + layer = presetLayer + } else { + let rawLayer = positional.first ?? prompt("레이어를 입력하세요 (Feature/Core/Service/Domain/UI)") + guard let parsed = ModuleLayer(argument: rawLayer) else { + FileHandle.standardError.write(Data("알 수 없는 레이어: \(rawLayer)\n".utf8)) + return 64 + } + layer = parsed + positional = Array(positional.dropFirst()) } - let pattern = "static let (\\w+)" - let regex = try? NSRegularExpression(pattern: pattern) - let lines = content.components(separatedBy: .newlines) - return lines.compactMap { line in - guard let match = regex?.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)), - let range = Range(match.range(at: 1), in: line) else { return nil } - return String(line[range]) + + let name = positional.first ?? prompt("\(layer.rawValue) 모듈 이름을 입력하세요") + guard !name.isEmpty else { + FileHandle.standardError.write(Data("모듈 이름이 필요합니다.\n".utf8)) + return 64 } -} -// MARK: - Module Auto Registration Helper -func addModuleToPluginAutomatically(moduleName: String, layer: String) -> Bool { - let modulesFilePath = "Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift" + let caseName = caseNameOverride ?? defaultCaseName(for: name, layer: layer) - guard FileManager.default.fileExists(atPath: modulesFilePath) else { - print("❌ Modules.swift 파일을 찾을 수 없습니다: \(modulesFilePath)") - return false + let scaffoldStatus = runTuist(arguments: [ + "scaffold", "Module", + "--layer", layer.rawValue, + "--name", name, + "--author", scaffoldAuthor, + ]) + guard scaffoldStatus == 0 else { return scaffoldStatus } + + // 카탈로그에 case 가 있어야 `.feature(.x)` 같은 의존성 표기가 컴파일된다. + guard insertLine( + " case \(caseName) = \"\(name)\"", + afterLineContaining: "public enum \(layer.catalogEnumName): String, CaseIterable {", + skipIfContains: "= \"\(name)\"", + inFileAt: moduleCatalogPath + ) else { + return 1 } - do { - var content = try String(contentsOfFile: modulesFilePath, encoding: .utf8) - let originalContent = content - - // 레이어별 enum 이름 매핑 - let enumName: String - switch layer { - case "Presentation": - enumName = "Presentations" - case "Shared": - enumName = "Shareds" - case "Domain": - enumName = "Domains" - case "Network": - enumName = "Networks" - case "Data": - enumName = "Datas" - default: - print("❌ 알 수 없는 레이어: \(layer)") - return false + if let umbrellaManifestPath = layer.umbrellaManifestPath { + guard insertLine( + " .\(layer.dependencyHelperName)(.\(caseName)),", + afterLineContaining: "dependencies: [", + skipIfContains: ".\(layer.dependencyHelperName)(.\(caseName))", + inFileAt: umbrellaManifestPath + ) else { + return 1 } + } else { + print("\(layer.rawValue) 레이어는 엄브렐러가 없어 의존성 등록을 건너뜁니다.") + } - // enum 찾기 및 case 추가 - let enumPattern = "enum \(enumName): String, CaseIterable \\{([\\s\\S]*?)\\}" + return runTuist(arguments: ["generate"]) +} + +private func printHelp() { + print( + """ + 🚀 Picke Tuist 도구 + + 기본 명령어: + ./make setup # mise 설치 + Dashboard 확인 + install + 외부 캐시 준비 + generate + ./make generate # Demo 앱을 포함해 프로젝트 생성 + ./make build # 클린 + 의존성 설치 + 프로젝트 생성 + ./make install # 의존성 설치 + 프로젝트 생성 + ./make cache # 외부 바이너리 캐시 준비 + ./make cache:setup # 외부 바이너리 캐시 준비(cache 별칭) + ./make test # 전체 테스트 실행 + ./make format # SwiftFormat 적용 + ./make lint # SwiftFormat 검사 + ./make clean # 프로젝트 정리 + ./make reset # 앱 DerivedData 정리 + clean + install + generate + ./make edit # 매니페스트를 Xcode 로 열기 + + 점검: + ./make inspect # 프로젝트 구조 분석 + ./make inspect-imports # 암시적 의존성 검사 + ./make inspect-coverage # 코드 커버리지 분석 + + 모듈 생성 (scaffold + 카탈로그 case + 엄브렐러 의존성 자동 등록): + ./make feature <이름> [--case <케이스명>] + ./make core <이름> [--case <케이스명>] + ./make service <이름> [--case <케이스명>] + ./make domain <이름> [--case <케이스명>] + ./make ui <이름> [--case <케이스명>] + ./make module <레이어> <이름> # 레이어를 인자로 받는 형태 + ./make moduleinit # module 명령의 호환 별칭 + + 케이스명은 모듈명에서 Picke 접두와 레이어 접미를 떼고 첫 글자를 소문자로 바꿔 만든다 + (PickeNetwork → network, AuthDomain → auth). 규칙과 다르면 --case 로 직접 지정한다. + + 의존성 그래프: + ./make graph # 외부 패키지·Demo를 제외하고 Tests·Testing·Interface를 포함한 모듈 그래프 생성 + ./make graph:prod # 외부 패키지·Demo·테스트 타깃을 제외한 그래프 생성 + """ + ) +} + +private func execute(_ command: Command, forwardedArguments: [String]) -> Int32 { + switch command { + case .setup: + var results: [(String, StepResult)] = [] + + let miseStatus = run("mise", arguments: ["install"]) + results.append(("mise 도구 설치", miseStatus == 0 ? .passed : .failed(miseStatus))) + guard miseStatus == 0 else { + printSetupSummary(results) + return miseStatus + } + + if usesBinaryCache(forwardedArguments: forwardedArguments), !isCIEnvironment { + let authStatus = prepareLocalTuistAccess(allowFailure: false) + results.append(("Tuist Dashboard 인증/프로젝트 확인", authStatus == 0 ? .passed : .failed(authStatus))) + guard authStatus == 0 else { + printSetupSummary(results) + return authStatus + } + } else { + let reason = isCIEnvironment ? "CI 환경" : "--no-binary-cache" + results.append(("Tuist Dashboard 인증/프로젝트 확인", .skipped(reason))) + } - guard let enumRegex = try? NSRegularExpression(pattern: enumPattern), - let enumMatch = enumRegex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), - let enumRange = Range(enumMatch.range, in: content) else { - print("❌ \(enumName) enum을 찾을 수 없습니다") - return false + let installStatus = runTuist(arguments: ["install"] + installArguments(forwardedArguments: forwardedArguments)) + results.append(("의존성 설치", installStatus == 0 ? .passed : .failed(installStatus))) + guard installStatus == 0 else { + printSetupSummary(results) + return installStatus } - // enum 내부 검사하여 중복 확인 - if let innerRange = Range(enumMatch.range(at: 1), in: content) { - let innerContent = String(content[innerRange]) - if innerContent.contains("case \(moduleName)") { - print("ℹ️ 모듈 '\(moduleName)'이 이미 \(enumName)에 존재합니다") - return true + if usesBinaryCache(forwardedArguments: forwardedArguments), !isCIEnvironment { + let cacheStatus = warmBinaryCache() + results.append(("외부 바이너리 캐시 준비", cacheStatus == 0 ? .passed : .failed(cacheStatus))) + guard cacheStatus == 0 else { + printSetupSummary(results) + return cacheStatus } + } else { + let reason = isCIEnvironment ? "CI 환경" : "--no-binary-cache" + results.append(("외부 바이너리 캐시 준비", .skipped(reason))) } - // 마지막 case 뒤에 새로운 case 추가 - let enumEndIndex = content.index(before: enumRange.upperBound) - let newCase = " case \(moduleName)\n " - content.insert(contentsOf: newCase, at: enumEndIndex) + let generateForwardedArguments = filteredGenerateArguments(forwardedArguments: forwardedArguments) + let generateStatus = runTuist( + arguments: ["generate"] + generateArguments(forwardedArguments: generateForwardedArguments) + ) + results.append(("프로젝트 생성", generateStatus == 0 ? .passed : .failed(generateStatus))) + printSetupSummary(results) + return generateStatus + + case .generate: + let authStatus = prepareBinaryCacheIfNeeded( + forwardedArguments: forwardedArguments, + allowFailure: true + ) + guard authStatus == 0 else { return authStatus } + return runTuist(arguments: ["generate"] + generateArguments(forwardedArguments: forwardedArguments)) - // 파일 업데이트 - if content != originalContent { - try content.write(toFile: modulesFilePath, atomically: true, encoding: .utf8) - print("✅ \(enumName)에 '\(moduleName)' 모듈이 자동으로 추가되었습니다") - return true - } + case .build: + let cleanStatus = runTuist(arguments: ["clean"]) + guard cleanStatus == 0 else { return cleanStatus } + return installAndGenerate(forwardedArguments: forwardedArguments) - } catch { - print("❌ Modules.swift 파일 업데이트 실패: \(error)") - return false - } + case .install: + return installAndGenerate(forwardedArguments: forwardedArguments) - return false -} + case .cache: + return warmBinaryCache(forwardedArguments: forwardedArguments) -// MARK: - registerModule -func registerModule() { - print("\n🚀 새 모듈 등록을 시작합니다.") - let moduleInput = prompt("모듈 이름을 입력하세요 (예: Presentation_Home, Shared_Logger, Domain_Auth 등)") - let moduleName = prompt("생성할 모듈 이름을 입력하세요 (예: Home)") + case .cacheSetup: + return warmBinaryCache(forwardedArguments: forwardedArguments) - // ✅ 모듈명 유효성 검사 - guard !moduleName.isEmpty else { - print("❌ 모듈명이 비어있습니다.") - return - } + case .test: + let authStatus = prepareBinaryCacheIfNeeded( + forwardedArguments: forwardedArguments, + allowFailure: true + ) + guard authStatus == 0 else { return authStatus } + return runTuist(arguments: ["test"] + generateArguments(forwardedArguments: forwardedArguments)) - guard moduleName.count >= 1 else { - print("❌ 모듈명이 올바르지 않습니다.") - return - } + case .format: + return run("mise", arguments: ["exec", "--", "swiftformat", "."] + forwardedArguments) - var dependencies: [String] = [] - while true { - print("의존성 종류 선택:") - print(" 1) SPM") - print(" 2) 내부 모듈") - print(" 3) 종료") - let choice = prompt("번호 선택") - if choice == "3" { break } - - if choice == "1" { - let options = parseSPMLibraries() - for (i, lib) in options.enumerated() { print(" \(i + 1). \(lib)") } - let selected = Int(prompt("선택할 번호 입력")) ?? 0 - if (1...options.count).contains(selected) { - dependencies.append(".SPM.\(options[selected - 1])") - } - } else if choice == "2" { - let types = availableModuleTypes() - for (i, type) in types.enumerated() { print(" \(i + 1). \(type)") } - let typeIndex = Int(prompt("의존할 모듈 타입 번호 입력")) ?? 0 - guard (1...types.count).contains(typeIndex) else { continue } - let keyword = types[typeIndex - 1] - - let options = parseModulesFromFile(keyword: keyword) - for (i, opt) in options.enumerated() { print(" \(i + 1). \(opt)") } - let moduleIndex = Int(prompt("선택할 번호 입력")) ?? 0 - if (1...options.count).contains(moduleIndex) { - dependencies.append(".\(keyword)(implements: .\(options[moduleIndex - 1]))") - } - } - } + case .lint: + return run("mise", arguments: ["exec", "--", "swiftformat", "--lint", "."] + forwardedArguments) - // 🧪 hasTests 옵션 선택 - print("\n🧪 테스트 설정:") - let hasTestsChoice = prompt("이 모듈에 테스트를 포함하시겠습니까? (y/N)").lowercased() - let hasTests = hasTestsChoice == "y" || hasTestsChoice == "yes" - - let author = (try? runCapture("git", arguments: ["config", "--get", "user.name"])) ?? "Unknown" - let formatter = DateFormatter(); formatter.dateFormat = "yyyy-MM-dd" - let currentDate = formatter.string(from: Date()) - - let layer: String = { - let lower = moduleInput.lowercased() - if lower.starts(with: "presentation") { return "Presentation" } - else if lower.starts(with: "shared") { return "Shared" } - else if lower.starts(with: "domain") { return "Domain" } - else if lower.starts(with: "network") { return "Network" } - else if lower.starts(with: "data") { return "Data" } - else { return "Shared" } // 기본값을 Shared로 변경 - }() - - let result = run("tuist", arguments: [ - "scaffold", "Module", - "--layer", layer, - "--name", moduleName, - "--author", author, - "--current-date", currentDate - ]) + case .clean: + return runTuist(arguments: ["clean"] + forwardedArguments) - if result == 0 { - let projectFile = "Projects/\(layer)/\(moduleName)/Project.swift" + case .reset: + return resetProject() - // Project.swift 파일을 완전히 다시 작성 - let dependencyList = dependencies.isEmpty ? "" : "\n " + dependencies.joined(separator: ",\n ") + "," + case .edit: + return runTuist(arguments: ["edit"] + forwardedArguments) - let projectContent = """ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.makeAppModule( - name: "\(moduleName)", - bundleId: .appBundleID(name: ".\(moduleName)"), - product: .staticFramework, - settings: .settings(), - dependencies: [\(dependencyList) - ], - sources: ["Sources/**"]\(hasTests ? ",\n hasTests: true" : "") -) -""" - - do { - try projectContent.write(toFile: projectFile, atomically: true, encoding: .utf8) - print("✅ Project.swift 파일 생성 완료") - if !dependencies.isEmpty { - print("✅ 의존성 추가: \(dependencies.count)개") - } - if hasTests { - print("✅ hasTests: true 추가 - 템플릿에서 Tests/Sources 구조 자동 생성됨") - } else { - print("ℹ️ hasTests: false - Tests 폴더는 생성되지만 프로젝트에 포함되지 않음") - } - } catch { - print("❌ Project.swift 파일 작성 실패: \(error)") - } + case .inspect: + return runTuist(arguments: ["inspect"] + forwardedArguments) - // ✅ 자동으로 Modules.swift에 모듈 추가 - print("\n📝 Modules.swift에 모듈 등록 중...") - if addModuleToPluginAutomatically(moduleName: moduleName, layer: layer) { - print("✅ Modules.swift 등록 완료") - } else { - print("⚠️ Modules.swift 등록 실패 - 수동으로 추가해주세요") - } + case .inspectImports: + return runTuist(arguments: ["inspect", "implicit-imports"] + forwardedArguments) - print("✅ 모듈 생성 완료: Projects/\(layer)/\(moduleName)") - - // ────────────────────────────── - // ✅ Domain 모듈일 경우 Interface 폴더 생성 여부 확인 - if layer == "Domain" { - let askInterface = prompt("이 Domain 모듈에 Interface 폴더를 생성할까요? (y/N)").lowercased() - if askInterface == "y" { - let interfaceDir = "Projects/Domain/\(moduleName)/Interface/Sources" - let baseFilePath = "\(interfaceDir)/Base.swift" - - if !FileManager.default.fileExists(atPath: interfaceDir) { - do { - try FileManager.default.createDirectory(atPath: interfaceDir, withIntermediateDirectories: true, attributes: nil) - print("📂 Interface 폴더 생성 → \(interfaceDir)") - } catch { - print("❌ Interface 폴더 생성 실패: \(error)") - } - } else { - print("ℹ️ Interface 폴더 이미 존재 → 건너뜀") - } - - // Base.swift 생성(없으면) - if !FileManager.default.fileExists(atPath: baseFilePath) { - let baseTemplate = """ - // - // Base.swift - // Domain.\(moduleName).Interface - // - // Created by \(author) on \(currentDate). - // - - import Foundation - - public protocol \(moduleName)Interface { - // TODO: 정의 추가 - } - """ - do { - try baseTemplate.write(toFile: baseFilePath, atomically: true, encoding: .utf8) - print("✅ Base.swift 생성 → \(baseFilePath)") - } catch { - print("❌ Base.swift 생성 실패: \(error)") - } - } else { - print("ℹ️ Base.swift 이미 존재 → 건너뜀") - } - } - } - } else { - print("❌ 모듈 생성 실패") - } -} + case .inspectCoverage: + return runTuist(arguments: ["inspect", "code-coverage"] + forwardedArguments) -// MARK: - XConfig 파일 업데이트 -private func updateXConfigFiles(newName: String) { - print("🔧 xconfig 파일들 업데이트 중...") - - let configFiles = ["Dev.xcconfig", "Stage.xcconfig", "Prod.xcconfig", "Release.xcconfig"] - - for configFile in configFiles { - let configPath = "Config/\(configFile)" - - guard FileManager.default.fileExists(atPath: configPath) else { - print("⚠️ \(configFile) 파일을 찾을 수 없습니다: \(configPath)") - continue - } - - do { - var content = try String(contentsOfFile: configPath, encoding: .utf8) - let originalContent = content - - // 이미 동적 설정된 경우는 건너뛰기 - if content.contains("PRODUCT_NAME = $(PROJECT_NAME)") && content.contains("BUNDLE_DISPLAY_NAME = $(PROJECT_NAME)") { - print("ℹ️ \(configFile) 이미 동적 설정됨") - continue - } - - // 하드코딩된 프로젝트 이름을 동적 참조로 변경 - let patterns = [ - (#"PRODUCT_NAME = [^$\n\r]*$"#, "PRODUCT_NAME = $(PROJECT_NAME)"), - (#"PRODUCT_NAME = [^$\n\r]*-Dev$"#, "PRODUCT_NAME = $(PROJECT_NAME)-Dev"), - (#"PRODUCT_NAME = [^$\n\r]*-Stage$"#, "PRODUCT_NAME = $(PROJECT_NAME)-Stage"), - (#"PRODUCT_NAME = [^$\n\r]*-Prod$"#, "PRODUCT_NAME = $(PROJECT_NAME)-Prod"), - (#"BUNDLE_DISPLAY_NAME = [^$\n\r]*$"#, "BUNDLE_DISPLAY_NAME = $(PROJECT_NAME)"), - (#"BUNDLE_DISPLAY_NAME = [^$\n\r]*\(Dev\)$"#, "BUNDLE_DISPLAY_NAME = $(PROJECT_NAME)(Dev)"), - (#"BUNDLE_DISPLAY_NAME = [^$\n\r]*\(Stage\)$"#, "BUNDLE_DISPLAY_NAME = $(PROJECT_NAME)(Stage)"), - (#"BUNDLE_DISPLAY_NAME = [^$\n\r]*\(Prod\)$"#, "BUNDLE_DISPLAY_NAME = $(PROJECT_NAME)(Prod)") - ] - - for (pattern, replacement) in patterns { - content = content.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) - } - - if content != originalContent { - try content.write(toFile: configPath, atomically: true, encoding: .utf8) - print("✅ \(configFile) 업데이트 완료") - } else { - print("ℹ️ \(configFile) 변경사항 없음") - } - - } catch { - print("❌ \(configFile) 업데이트 실패: \(error)") - } - } + case .module, .moduleInit: + return scaffoldModule(layer: nil, arguments: forwardedArguments) - print("✅ xconfig 파일들 업데이트 완료") -} + case .feature: + return scaffoldModule(layer: .feature, arguments: forwardedArguments) -// MARK: - Entrypoint -enum Command: String { - case edit, generate, fetch, build, clean, install, cache, reset, moduleinit, newproject - case inspect, inspectimports = "inspect-imports", inspectcoverage = "inspect-coverage" -} + case .core: + return scaffoldModule(layer: .core, arguments: forwardedArguments) + + case .service: + return scaffoldModule(layer: .service, arguments: forwardedArguments) -let args = CommandLine.arguments.dropFirst() -guard let cmd = args.first, let command = Command(rawValue: cmd) else { - print(""" - 🚀 Tuist 4.97.2 도구 사용법: - ./tuisttool generate # 프로젝트 생성 - ./tuisttool build # 클린 + 의존성 설치 + 생성 - ./tuisttool install # 의존성 설치 (새로운 명령어) - ./tuisttool cache # 바이너리 캐시 생성 - ./tuisttool clean # 프로젝트 정리 - ./tuisttool reset # 전체 캐시 리셋 - ./tuisttool moduleinit # 새 모듈 생성 - ./tuisttool inspect # 프로젝트 구조 분석 - ./tuisttool inspect-imports # 암시적 의존성 검사 - ./tuisttool inspect-coverage # 코드 커버리지 분석 - ./tuisttool newproject [옵션...] # 새 프로젝트 생성 - - 새 프로젝트 생성 예시: - ./tuisttool newproject # 대화형으로 입력 - ./tuisttool newproject MyAwesomeApp # 간단한 사용법 - ./tuisttool newproject MyApp --bundle-id com.company.app --team-id ABC123DEF - """) - exit(1) + case .domain: + return scaffoldModule(layer: .domain, arguments: forwardedArguments) + + case .ui: + return scaffoldModule(layer: .ui, arguments: forwardedArguments) + + case .graph: + return renderGraph( + excludesDemo: true, + extraTuistArguments: [], + forwardedArguments: forwardedArguments + ) + + case .productionGraph: + return renderGraph( + excludesDemo: true, + extraTuistArguments: ["--skip-test-targets"], + forwardedArguments: forwardedArguments + ) + + case .help: + printHelp() + return 0 + } } -switch command { - case .edit: edit() - case .generate: generate() - case .fetch: fetch() - case .build: build() - case .clean: clean() - case .install: install() - case .cache: cache() - case .reset: reset() - case .moduleinit: registerModule() - case .inspect: inspect() - case .inspectimports: inspectImplicitImports() - case .inspectcoverage: inspectCodeCoverage() - case .newproject: - // 인자가 있으면 인자로 처리, 없으면 대화형으로 처리 - if CommandLine.arguments.count > 2 { - generateProjectWithArgs() - } else { - newProject() - } +let arguments = Array(CommandLine.arguments.dropFirst()) +let rawCommand = arguments.first ?? Command.help.rawValue +let normalizedCommand = ["-h", "--help"].contains(rawCommand) ? Command.help.rawValue : rawCommand +let forwardedArguments = Array(arguments.dropFirst()) + +guard let command = Command(rawValue: normalizedCommand) else { + FileHandle.standardError.write(Data("알 수 없는 명령어: \(rawCommand)\n\n".utf8)) + printHelp() + exit(64) } + +exit(execute(command, forwardedArguments: forwardedArguments)) diff --git a/WorkSpace.swift b/WorkSpace.swift index 7e8e0d3b..733b167f 100644 --- a/WorkSpace.swift +++ b/WorkSpace.swift @@ -5,22 +5,22 @@ // Created by 서원지 on 6/7/24. // -import Foundation import ProjectDescription import ProjectTemplatePlugin -let workspaceName: String = { - if let projectName = ProcessInfo.processInfo.environment["PROJECT_NAME"] { - print("🔍 PROJECT_NAME 환경변수 발견: \(projectName)") - return projectName - } else { - print("🎵 ProjectConfig에서 프로젝트 이름 사용: \(ProjectConfig.projectName)") - return ProjectConfig.projectName - } -}() - +// 이름은 Project.Environment.appName 하나로만 정한다. +// PROJECT_NAME 환경변수 폴백은 이미 그 안에 있어서 여기서 또 분기하면 로직만 두 벌이 된다. +// +// Stage 스킴은 App 프로젝트의 appSchemes 가 이미 만든다. 워크스페이스에서 또 만들면 +// 같은 이름이 두 번 생기므로 여기서는 커버리지 수집 범위만 정한다. let workspace = Workspace( -name: workspaceName, -projects: [ + name: Project.Environment.appName, + projects: [ "Projects/**" -]) + ], + generationOptions: .options( + // 커버리지 대상은 스킴과 관련된 타깃으로 Tuist 가 좁힌다. + // 모듈을 손으로 나열하면 추가·개명할 때마다 목록이 실제 타깃과 어긋난다. + autogeneratedWorkspaceSchemes: .enabled(codeCoverageMode: .relevant) + ) +) diff --git a/bitrise.yml b/bitrise.yml index cfeeb6d8..b7688975 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -11,7 +11,7 @@ meta: app: envs: - CONFIG_REPO: SWYP-Find/iOS-Config - - TUIST_VERSION: 4.154.0 + - TUIST_VERSION: 4.207.0 - REPO: SWYP-Find/Picke-iOS # Sentry — SENTRY_AUTH_TOKEN 은 Bitrise Secrets 에 등록한다(코드에 두지 않음). # 토큰이 없으면 Fastfile 의 upload_build_to_sentry 가 스스로 스킵하므로 배포는 계속된다. diff --git a/docs/domain-data-feature-architecture.md b/docs/domain-data-feature-architecture.md index 407ddf02..fc29196b 100644 --- a/docs/domain-data-feature-architecture.md +++ b/docs/domain-data-feature-architecture.md @@ -82,12 +82,12 @@ OAuth(Apple/Google/Kakao), AudioPlayer, Deeplink, Error, Share, Base/Common. **"xcodeproj 수는 A 수준, 경계는 B 수준"** — Presentation 이 이미 쓰는 마이크로피처 패턴 그대로. -- `Projects/Domain//` = 1 xcodeproj, 내부 4타깃: +- `Projects/Domain/Domain/` = 1 xcodeproj, 내부 4타깃: - `DomainInterface` (Entity + Repository 프로토콜 + UseCase 프로토콜) — 기존 Entity/DomainInterface 폴더 병합 - `Domain` (UseCase 구현) - `DomainTesting` (목 — 기존 DomainTesting·DataTesting 의 해당 feature 목 흡수) - `DomainTests` -- `Projects/Data//` = 1 xcodeproj, 내부 2타깃: +- `Projects/Data/Data/` = 1 xcodeproj, 내부 2타깃: - `Data` (API + Model + Service + Repository 를 **폴더로** 내장 — Data 내부 레이어는 한 feature 안에서 항상 같이 바뀌므로 타깃 분리 실익 없음) - `DataTests` - Data 쪽에 Interface 타깃을 두지 않는 이유: Repository 가 구현하는 계약은 이미 `DomainInterface` 에 있다. DataInterface 는 내용이 없는 빈 타깃이 된다 — 단순함 우선. @@ -240,7 +240,7 @@ App ──▶ Domain(엄브렐라)·Data(엄브렐라) [DI 등록용] ### 7.1 ModuleType — 기존 `microModule` 활용, 신규 case 불필요 ```swift -// Domain feature — Projects/Domain/Auth/Project.swift (예시) +// Domain feature — Projects/Domain/AuthDomain/Project.swift (예시) let project = Project.configure( moduleType: .microModule(name: "AuthDomain"), // 이미 존재, configureFeature 위임 bundleId: .appBundleID(name: ".AuthDomain"), @@ -249,7 +249,7 @@ let project = Project.configure( dependencies: [ .SPM.weaveDI, .SPM.composableArchitecture ] ) -// Data feature — Projects/Data/Auth/Project.swift (예시) +// Data feature — Projects/Data/AuthData/Project.swift (예시) let project = Project.configure( moduleType: .module(name: "AuthData"), bundleId: .appBundleID(name: ".AuthData"), diff --git a/docs/graphs/Picke-expanded-Picke.png b/docs/graphs/Picke-expanded-Picke.png deleted file mode 100644 index 798fcd80..00000000 Binary files a/docs/graphs/Picke-expanded-Picke.png and /dev/null differ diff --git a/docs/graphs/Picke-grouped-Picke.png b/docs/graphs/Picke-grouped-Picke.png deleted file mode 100644 index 056b2679..00000000 Binary files a/docs/graphs/Picke-grouped-Picke.png and /dev/null differ diff --git a/docs/grpah/Picke-expanded-CoreAssembly.png b/docs/grpah/Picke-expanded-CoreAssembly.png new file mode 100644 index 00000000..57d7c278 Binary files /dev/null and b/docs/grpah/Picke-expanded-CoreAssembly.png differ diff --git a/docs/grpah/Picke-expanded-DomainAssembly.png b/docs/grpah/Picke-expanded-DomainAssembly.png new file mode 100644 index 00000000..6d299071 Binary files /dev/null and b/docs/grpah/Picke-expanded-DomainAssembly.png differ diff --git a/docs/grpah/Picke-expanded-FeatureAssembly.png b/docs/grpah/Picke-expanded-FeatureAssembly.png new file mode 100644 index 00000000..f99dfa56 Binary files /dev/null and b/docs/grpah/Picke-expanded-FeatureAssembly.png differ diff --git a/docs/grpah/Picke-expanded-Picke.png b/docs/grpah/Picke-expanded-Picke.png new file mode 100644 index 00000000..c8ca9a4b Binary files /dev/null and b/docs/grpah/Picke-expanded-Picke.png differ diff --git a/docs/grpah/Picke-expanded-PickeSharedUI.png b/docs/grpah/Picke-expanded-PickeSharedUI.png new file mode 100644 index 00000000..f8f8c6c3 Binary files /dev/null and b/docs/grpah/Picke-expanded-PickeSharedUI.png differ diff --git a/docs/grpah/Picke-expanded-ServiceAssembly.png b/docs/grpah/Picke-expanded-ServiceAssembly.png new file mode 100644 index 00000000..4214966e Binary files /dev/null and b/docs/grpah/Picke-expanded-ServiceAssembly.png differ diff --git a/docs/grpah/Picke-grouped-Picke.png b/docs/grpah/Picke-grouped-Picke.png new file mode 100644 index 00000000..95521bdf Binary files /dev/null and b/docs/grpah/Picke-grouped-Picke.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9c8a8f59..2a848585 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -179,7 +179,7 @@ platform :ios do # 갱신된 빌드 번호 반영을 위해 Tuist 강제 재생성 Dir.chdir("..") do puts "🔧 Tuist workspace 재생성 중..." - sh("tuist generate --no-open") + sh("tuist generate --no-binary-cache --no-open") puts "✅ Tuist workspace 재생성 완료" end @@ -215,14 +215,14 @@ platform :ios do begin if File.exist?("make") - puts "🔧 Trying ./make install..." - sh("./make install") + puts "🔧 Trying Tuist install..." + sh("mise exec -- tuist install") puts "🔧 Trying ./make generate..." - sh("./make generate") + sh("./make generate --no-binary-cache --no-open") else puts "🔧 Trying tuist directly..." sh("tuist install") - sh("tuist generate --no-open") + sh("tuist generate --no-binary-cache --no-open") end rescue => e puts "❌ Generation failed: #{e.message}" @@ -330,7 +330,7 @@ platform :ios do # Tuist 프로젝트 재생성 Dir.chdir("..") do puts "🔧 Tuist workspace 재생성 중..." - sh("tuist generate --no-open") + sh("tuist generate --no-binary-cache --no-open") puts "✅ Tuist workspace 재생성 완료" end @@ -366,14 +366,14 @@ platform :ios do begin if File.exist?("make") - puts "🔧 ./make install 실행 중..." - sh("./make install") + puts "🔧 Tuist install 실행 중..." + sh("mise exec -- tuist install") puts "🔧 ./make generate 실행 중..." - sh("./make generate") + sh("./make generate --no-binary-cache --no-open") else puts "🔧 tuist 직접 실행 중..." sh("tuist install") - sh("tuist generate --no-open") + sh("tuist generate --no-binary-cache --no-open") end rescue => e puts "❌ 생성 실패: #{e.message}" @@ -504,7 +504,7 @@ platform :ios do end Dir.chdir("..") do - sh("tuist generate --no-open") + sh("tuist generate --no-binary-cache --no-open") build_app( workspace: File.expand_path("Picke.xcworkspace"), scheme: SCHEME, diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 4370553a..11cc749c 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -1,4 +1,4 @@ -git_url("git@github.com:Roy-wonji/FastlaneMatch.git") +git_url("git@github.com:SWYP-Find/Fastlane-match.git") storage_mode("git") app_identifier(["io.Picke.co"]) include_mac_in_profiles(true) @@ -11,18 +11,18 @@ keychain_name(ENV["MATCH_KEYCHAIN_NAME"] || "match_keychain") keychain_password(ENV["MATCH_KEYCHAIN_PASSWORD"]) # .env 파일에서 API 키가 있으면 임시 JSON 파일 생성 후 사용 완료시 자동 삭제 -if ENV["APP_STORE_CONNECT_API_KEY_ID"] && ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] && ENV["APP_STORE_CONNECT_API_KEY_KEY_CONTENT"] +if ENV["APP_STORE_CONNECT_API_KEY_ID"] && ENV["APP_STORE_CONNECT_ISSUER_ID"] && ENV["APP_STORE_CONNECT_API_KEY_CONTENT"] require 'json' require 'base64' api_key_path = "fastlane/AuthKey_#{ENV['APP_STORE_CONNECT_API_KEY_ID']}.json" # 임시 JSON 파일 생성 - decoded_key = Base64.decode64(ENV["APP_STORE_CONNECT_API_KEY_KEY_CONTENT"]) + decoded_key = Base64.decode64(ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]) api_key_data = { "key_id" => ENV["APP_STORE_CONNECT_API_KEY_ID"], - "issuer_id" => ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], + "issuer_id" => ENV["APP_STORE_CONNECT_ISSUER_ID"], "key" => decoded_key, "duration" => 1200, "in_house" => false diff --git a/graph.png b/graph.png index fc788bb1..e5c75d34 100644 Binary files a/graph.png and b/graph.png differ diff --git a/make b/make index 8f6a18cb..a12c1085 100755 Binary files a/make and b/make differ diff --git a/mise.toml b/mise.toml index 90d826fe..91237b94 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -tuist = "4.154.0" +tuist = "4.207.0"