diff --git a/Entitlements/TimeSpot.entitlements b/Entitlements/TimeSpot.entitlements
new file mode 100644
index 0000000..80b5221
--- /dev/null
+++ b/Entitlements/TimeSpot.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.developer.applesignin
+
+ Default
+
+
+
diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift
index 3951783..27daddd 100644
--- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift
@@ -22,6 +22,9 @@ public extension ModulePath {
case Presentation
case Splash
case Home
+ case Auth
+ case OnBoarding
+ case Profile
public static let name: String = "Presentation"
@@ -36,8 +39,8 @@ public extension ModulePath {
case Foundations
public static let name: String = "Network"
- case Networks
- case ThirdPartys
+ case Networks
+ case ThirdPartys
}
}
@@ -64,7 +67,6 @@ public extension ModulePath {
case DataInterface
case DomainInterface
-
public static let name: String = "Domain"
}
}
@@ -75,7 +77,7 @@ public extension ModulePath {
case Shared
case DesignSystem
case Utill
-
+
public static let name: String = "Shared"
}
}
diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift
index 041e760..803f8ac 100644
--- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift
+++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Project+Template.swift
@@ -160,6 +160,7 @@ public extension Project {
scripts: [ProjectDescription.TargetScript] = [],
dependencies: [ProjectDescription.TargetDependency] = [],
sources: ProjectDescription.SourceFilesList = ["Sources/**"],
+ testSources: ProjectDescription.SourceFilesList = ["Tests/Sources/**"],
resources: ProjectDescription.ResourceFileElements? = nil,
infoPlist: ProjectDescription.InfoPlist = .default,
entitlements: ProjectDescription.Entitlements? = nil,
@@ -194,7 +195,7 @@ public extension Project {
bundleId: "\(bundleId).\(name)Tests",
deploymentTargets: deploymentTarget,
infoPlist: .default,
- sources: ["Tests/Sources/**"],
+ sources: testSources,
dependencies: [.target(name: name)]
)
targets.append(appTestTarget)
diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift
index 38884aa..73049ee 100644
--- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift
+++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift
@@ -40,6 +40,9 @@ extension Settings {
.setCFBundleDisplayName(Project.Environment.appName)
.setMarketingVersion(.appVersion())
.setEnableBackgroundModes()
+ .setASAuthenticationServicesEnabled()
+ .setPushNotificationsEnabled()
+ .setEnableBackgroundModes()
.setArchs()
.setOtherLdFlags()
.setCurrentProjectVersion(.appBuildVersion())
diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift
index 32da490..3c22171 100644
--- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift
+++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift
@@ -104,10 +104,34 @@ extension InfoPlistDictionary {
func setCFBundleURLTypes() -> InfoPlistDictionary {
let dict: [String: Plist.Value] = [
"CFBundleURLTypes": .array([
+ // TimeSpot 앱 스킴
.dictionary([
+ "CFBundleURLName": .string("timespot"),
+ "CFBundleURLSchemes": .array([
+ .string("timespot")
+ ])
+ ]),
+ // 구글 OAuth
+ .dictionary([
+ "CFBundleURLName": .string("google-oauth"),
"CFBundleURLSchemes": .array([
.string("${REVERSED_CLIENT_ID}")
-// .string("com.googleusercontent.apps.882277748169-glpolfiecue4lqqps6hmgj9t8lm1g5qp")
+ ])
+ ]),
+ // 구글 지도
+ .dictionary([
+ "CFBundleURLName": .string("google-maps"),
+ "CFBundleURLSchemes": .array([
+ .string("googlemaps"),
+ .string("comgooglemaps")
+ ])
+ ]),
+ // 네이버 지도
+ .dictionary([
+ "CFBundleURLName": .string("naver-maps"),
+ "CFBundleURLSchemes": .array([
+ .string("nmap"),
+ .string("nmapmobile")
])
])
])
@@ -181,7 +205,10 @@ extension InfoPlistDictionary {
func setGoogleClientID(_ value: String) -> InfoPlistDictionary {
return self.merging(["GOOGLE_CLIENT_ID": .string(value)]) { (_, new) in new }
}
-
+ func setGoogleClientiOSID(_ value: String) -> InfoPlistDictionary {
+ return self.merging(["GOOGLE_IOS_CLIENT_ID": .string(value)]) { (_, new) in new }
+ }
+
func setBaseURL(_ value: String) -> InfoPlistDictionary {
return self.merging(["BASE_URL": .string(value)]) { (_, new) in new }
}
diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift
index 18f4ea8..7951818 100644
--- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift
+++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift
@@ -43,6 +43,11 @@ public extension InfoPlist {
.setBaseURL("$(BASE_URL)")
.setNMFGovClientId("$(NMFGovClientId)")
.setNMFGovClientSecret("$(NMFGovClientSecret)")
+ .setGoogleReversedClientID("${REVERSED_CLIENT_ID}")
+ .setGoogleClientID("${GOOGLE_CLIENT_ID}")
+ .setGoogleClientiOSID("${GOOGLE_IOS_CLIENT_ID}")
+ .setGIDClientID("${GOOGLE_CLIENT_ID}")
+
.setUILaunchScreens()
.setLocationPermissions()
diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift
index 703ac56..d414820 100644
--- a/Projects/App/Project.swift
+++ b/Projects/App/Project.swift
@@ -14,13 +14,15 @@ let project = Project.makeAppModule(
.Data(implements: .Repository)
],
sources: ["Sources/**"],
- resources: ["Resources/**"],
+ resources: ["Resources/**", "FontAsset/**"],
infoPlist: .appInfoPlist,
+ entitlements: .file(path: "../../Entitlements/TimeSpot.entitlements"),
schemes: [
// 테스트 플랜 스킴: 커스텀 구성명 사용 (.dev / .stage / .prod 중 택1)
Scheme.makeTestPlanScheme(target: .dev, name: Project.Environment.appName),
],
- hasTests: true
+ hasTests: true,
+
)
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
index 9221b9b..2a7279b 100644
--- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,93 +1,33 @@
{
"images" : [
{
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "20x20"
- },
- {
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "20x20"
- },
- {
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "29x29"
- },
- {
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "29x29"
- },
- {
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "40x40"
- },
- {
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "40x40"
- },
- {
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "60x60"
- },
- {
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "60x60"
- },
- {
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "20x20"
- },
- {
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "20x20"
- },
- {
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "29x29"
- },
- {
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "29x29"
- },
- {
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "40x40"
- },
- {
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "40x40"
- },
- {
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "76x76"
- },
- {
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "76x76"
+ "filename" : "darklogo 1.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
},
{
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "83.5x83.5"
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "darklogo.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
},
{
- "idiom" : "ios-marketing",
- "scale" : "1x",
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "filename" : "lightlogo.png",
+ "idiom" : "universal",
+ "platform" : "ios",
"size" : "1024x1024"
}
],
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png
new file mode 100644
index 0000000..c0b0d7d
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo.png
new file mode 100644
index 0000000..c0b0d7d
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/lightlogo.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/lightlogo.png
new file mode 100644
index 0000000..46cc351
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/lightlogo.png differ
diff --git a/Projects/App/Resources/FontAsset/PretendardVariable.ttf b/Projects/App/Resources/FontAsset/PretendardVariable.ttf
new file mode 100644
index 0000000..19063ad
Binary files /dev/null and b/Projects/App/Resources/FontAsset/PretendardVariable.ttf differ
diff --git a/Projects/App/Resources/GoogleService-Info.plist b/Projects/App/Resources/GoogleService-Info.plist
new file mode 100644
index 0000000..2bb01db
--- /dev/null
+++ b/Projects/App/Resources/GoogleService-Info.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ CLIENT_ID
+ 675750881243-906jvg87rchck7ao25ffmcttebpcuc5o.apps.googleusercontent.com
+ REVERSED_CLIENT_ID
+ com.googleusercontent.apps.675750881243-906jvg87rchck7ao25ffmcttebpcuc5o
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ io.TimeSpot.co
+
+
\ No newline at end of file
diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift
index 89d65d2..6e28cf9 100644
--- a/Projects/App/Sources/Application/AppDelegate.swift
+++ b/Projects/App/Sources/Application/AppDelegate.swift
@@ -21,7 +21,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
// 네이버맵 초기화 (Home 모듈의 NaverMapInitializer 사용)
- NaverMapInitializer.shared.initialize()
+ NaverMapInitializer.initialize()
return true
}
diff --git a/Projects/App/Sources/Application/NomadSpotApp.swift b/Projects/App/Sources/Application/TimeSpotApp.swift
similarity index 79%
rename from Projects/App/Sources/Application/NomadSpotApp.swift
rename to Projects/App/Sources/Application/TimeSpotApp.swift
index cf620e9..8697ec1 100644
--- a/Projects/App/Sources/Application/NomadSpotApp.swift
+++ b/Projects/App/Sources/Application/TimeSpotApp.swift
@@ -4,19 +4,19 @@ import SwiftUI
import ComposableArchitecture
@main
-struct NomadSpotApp: App {
+struct TimeSpotApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
- init() {
-
- }
-
var body: some Scene {
WindowGroup {
let store = Store(initialState: AppReducer.State()) {
+ #if DEBUG
AppReducer()
._printChanges()
._printChanges(.actionLabels)
+ #else
+ AppReducer()
+ #endif
}
AppView(store: store)
diff --git a/Projects/App/Sources/ContentView.swift b/Projects/App/Sources/ContentView.swift
index ed8b030..134c5a9 100644
--- a/Projects/App/Sources/ContentView.swift
+++ b/Projects/App/Sources/ContentView.swift
@@ -1,15 +1,12 @@
import SwiftUI
-
-public struct ContentView: View {
- public init() {}
-
- public var body: some View {
- Text("Hello, World!")
- .padding()
- }
-}
-
-
-#Preview {
- ContentView()
+import Presentation
+import ComposableArchitecture
+
+#Preview("login") {
+ LoginView(store: Store(
+ initialState: LoginFeature.State(),
+ reducer: {
+ LoginFeature()
+ }
+ ))
}
diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift
index 92b9d69..0737217 100644
--- a/Projects/App/Sources/Di/DiRegister.swift
+++ b/Projects/App/Sources/Di/DiRegister.swift
@@ -17,7 +17,8 @@ import WeaveDI
/// 🚀 **앱 전역 DI 관리자**
-public class AppDIManager: @unchecked Sendable {
+@MainActor
+public final class AppDIManager {
public static let shared = AppDIManager()
private init() {}
@@ -32,25 +33,18 @@ public class AppDIManager: @unchecked Sendable {
return KeychainTokenProvider(keychainManager: keychainManager) as TokenProviding
}
.register(DirectionInterface.self) { DirectionRepositoryImpl() }
-// .register(ProfileInterface.self) { ProfileRepositoryImpl() }
-// // MARK: - 로그인
-// .register { AuthRepositoryImpl() as AuthInterface }
-// .register { GoogleOAuthRepositoryImpl() as GoogleOAuthInterface }
-// .register { AppleLoginRepositoryImpl() as AppleAuthRequestInterface }
-// .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface }
-// .register { AppleOAuthProvider() as AppleOAuthProviderInterface }
-// .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface }
-// // MARK: - 온보딩
-// .register { OnBoardingRepositoryImpl() as OnBoardingInterface }
-// .register { SignUpRepositoryImpl() as SignUpInterface }
-// // MARK: - 출석
-// .register { AttendanceRepositoryImpl() as AttendanceInterface }
-// // MARK: - 마이페이지
-// .register { MyPageRepositoryImpl() as MyPageRepositoryInterface }
-// // MARK: - 스케줄
-// .register { ScheduleRepositoryImpl() as ScheduleInterface }
-// // MARK: - QRCode
-// .register { QRCodeRepositoryImpl() as QRCodeInterface }
+ // MARK: - 로그인
+ .register { AuthRepositoryImpl() as AuthInterface }
+ .register { GoogleOAuthRepositoryImpl() as GoogleOAuthInterface }
+ .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface }
+ .register { AppleLoginRepositoryImpl() as AppleAuthRequestInterface }
+ .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface }
+ .register { AppleOAuthProvider() as AppleOAuthProviderInterface }
+ // MARK: - 회원가입
+ .register { SignUpRepositoryImpl() as SignUpInterface }
+
+
+
.configure()
}
}
diff --git a/Projects/App/Sources/Di/KeychainTokenProvider.swift b/Projects/App/Sources/Di/KeychainTokenProvider.swift
index b89e473..75ef6bf 100644
--- a/Projects/App/Sources/Di/KeychainTokenProvider.swift
+++ b/Projects/App/Sources/Di/KeychainTokenProvider.swift
@@ -10,7 +10,7 @@ import Foundation
import DomainInterface
import Foundations
-struct KeychainTokenProvider: TokenProviding {
+final class KeychainTokenProvider: TokenProviding, @unchecked Sendable {
private let keychainManager: KeychainManagingInterface
init(keychainManager: KeychainManagingInterface) {
@@ -18,10 +18,57 @@ struct KeychainTokenProvider: TokenProviding {
}
func accessToken() -> String? {
- keychainManager.accessToken()
+ // 캐싱된 토큰이 있으면 반환
+ if let cached = TokenCache.shared.token {
+ return cached
+ }
+
+ // 캐시가 없으면 비동기적으로 로드
+ Task {
+ let token = await keychainManager.accessToken()
+ TokenCache.shared.token = token
+ }
+
+ // 현재는 캐시된 값 또는 nil 반환
+ return TokenCache.shared.token
}
func saveAccessToken(_ token: String) {
- keychainManager.saveAccessToken(token)
+ // 캐시 업데이트
+ TokenCache.shared.token = token
+
+ // 백그라운드에서 비동기적으로 저장
+ Task {
+ do {
+ try await keychainManager.saveAccessToken(token)
+ } catch {
+ print("Failed to save access token: \(error)")
+ // 저장 실패 시 캐시도 초기화
+ TokenCache.shared.token = nil
+ }
+ }
+ }
+}
+
+// Thread-safe 토큰 캐시
+private final class TokenCache: @unchecked Sendable {
+ static let shared = TokenCache()
+
+ private var _token: String?
+ private let lock = NSLock()
+
+ private init() {}
+
+ var token: String? {
+ get {
+ lock.lock()
+ defer { lock.unlock() }
+ return _token
+ }
+ set {
+ lock.lock()
+ _token = newValue
+ lock.unlock()
+ }
}
}
diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift
index 2807ccc..166a8d6 100644
--- a/Projects/App/Sources/Reducer/AppReducer.swift
+++ b/Projects/App/Sources/Reducer/AppReducer.swift
@@ -18,6 +18,7 @@ public struct AppReducer: Sendable {
public enum State {
case splash(SplashReducer.State)
case home(HomeReducer.State)
+ case auth(AuthCoordinator.State)
public init() {
@@ -28,6 +29,7 @@ public struct AppReducer: Sendable {
var animationID: String {
switch self {
case .splash: return "splash"
+ case .auth: return "auth"
case .home: return "home"
}
}
@@ -70,16 +72,20 @@ public struct AppReducer: Sendable {
public enum ScopeAction {
case splash(SplashReducer.Action)
case home(HomeReducer.Action)
+ case auth(AuthCoordinator.Action)
}
@Dependency(\.continuousClock) var clock
+ private enum Constants {
+ static let splashTransitionDelay: Duration = .seconds(2)
+ }
+
private enum CancelID {
case refreshTokenExpiredListener
case splashRouting
case authEffects
- case staffEffects
- case memberEffects
+ case mainEffects
}
public var body: some ReducerOf {
@@ -107,6 +113,9 @@ public struct AppReducer: Sendable {
.ifCaseLet(\.home, action: \.scope.home) {
HomeReducer()
}
+ .ifCaseLet(\.auth, action: \.scope.auth) {
+ AuthCoordinator()
+ }
}
}
@@ -126,10 +135,9 @@ extension AppReducer {
return .none
case .presentAuth:
-// state = .auth(.init())
+ state = .auth(.init())
return .concatenate(
- .cancel(id: CancelID.staffEffects),
- .cancel(id: CancelID.memberEffects)
+ .cancel(id: CancelID.mainEffects),
)
}
@@ -146,13 +154,10 @@ extension AppReducer {
case .refreshTokenExpired:
// Refresh token이 만료된 경우 로그인 화면으로 이동
-
-// state = .auth(.init())
-
+ state = .auth(.init())
return .concatenate(
.cancel(id: CancelID.splashRouting),
- .cancel(id: CancelID.staffEffects),
- .cancel(id: CancelID.memberEffects)
+ .cancel(id: CancelID.mainEffects),
)
}
}
@@ -177,11 +182,22 @@ extension AppReducer {
) -> Effect {
switch action {
case .splash(.navigation(.presentHome)):
+ // 토큰이 있어서 메인 화면으로 이동
+ return .run { send in
+ try await clock.sleep(for: Constants.splashTransitionDelay)
+ await send(.view(.presentAuth))
+ }
+
+ case .splash(.navigation(.presentAuth)):
+ // 토큰이 없어서 로그인 화면으로 이동
return .run { send in
- try await clock.sleep(for: .seconds(0.3))
- await send(.view(.presentRoot))
+ try await clock.sleep(for: Constants.splashTransitionDelay)
+ await send(.view(.presentAuth))
}
+ case .auth(.navigation(.presentMain)):
+ // 로그인 완료 후 메인 화면으로
+ return .send(.view(.presentRoot))
default:
return .none
diff --git a/Projects/App/Sources/View/AppView.swift b/Projects/App/Sources/View/AppView.swift
index ac25909..4e08f89 100644
--- a/Projects/App/Sources/View/AppView.swift
+++ b/Projects/App/Sources/View/AppView.swift
@@ -28,11 +28,19 @@ struct AppView: View {
SplashView(store: store)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
}
+
+ case .auth:
+ if let store = store.scope(state: \.auth, action: \.scope.auth) {
+ AuthCoordinatorView(store: store)
+ .transition(.opacity.combined(with: .scale(scale: 0.98)))
+ }
+
case .home:
if let store = store.scope(state: \.home, action: \.scope.home) {
HomeView(store: store)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
}
+
}
}
diff --git a/Projects/Data/API/Project.swift b/Projects/Data/API/Project.swift
index f861039..5462e2e 100644
--- a/Projects/Data/API/Project.swift
+++ b/Projects/Data/API/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "API",
bundleId: .appBundleID(name: ".API"),
product: .staticFramework,
diff --git a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift
new file mode 100644
index 0000000..1e74551
--- /dev/null
+++ b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift
@@ -0,0 +1,25 @@
+//
+// AuthAPI.swift
+// API
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+public enum AuthAPI: String, CaseIterable {
+ case login
+ case logout
+ case refresh
+
+ public var description: String {
+ switch self {
+ case .login:
+ return "/login"
+ case .logout:
+ return "/logout"
+ case .refresh:
+ return "/refresh"
+ }
+ }
+}
diff --git a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift
new file mode 100644
index 0000000..6daac6b
--- /dev/null
+++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift
@@ -0,0 +1,30 @@
+//
+// TimeSpotDomain.swift
+// API
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+import AsyncMoya
+
+public enum TimeSpotDomain {
+ case auth
+ case profile
+}
+
+extension TimeSpotDomain: DomainType {
+ public var baseURLString: String {
+ return BaseAPI.base.apiDescription
+ }
+
+ public var url: String {
+ switch self {
+ case .auth:
+ return "api/v1/auth"
+ case .profile:
+ return "api/v1/users"
+ }
+ }
+}
diff --git a/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift b/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift
new file mode 100644
index 0000000..2c85b84
--- /dev/null
+++ b/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift
@@ -0,0 +1,21 @@
+//
+// SignUpAPI.swift
+// API
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+public enum SignUpAPI: String , CaseIterable {
+ case signUp
+
+ public var description: String {
+ switch self {
+ case .signUp:
+ return "/signup"
+ }
+ }
+
+}
+
diff --git a/Projects/Data/Model/Project.swift b/Projects/Data/Model/Project.swift
index f4ed56f..2055eb9 100644
--- a/Projects/Data/Model/Project.swift
+++ b/Projects/Data/Model/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Model",
bundleId: .appBundleID(name: ".Model"),
product: .staticFramework,
diff --git a/Projects/Data/Model/Sources/Auth/DTO/LoginResponseDTO.swift b/Projects/Data/Model/Sources/Auth/DTO/LoginResponseDTO.swift
new file mode 100644
index 0000000..ee52c8a
--- /dev/null
+++ b/Projects/Data/Model/Sources/Auth/DTO/LoginResponseDTO.swift
@@ -0,0 +1,75 @@
+//
+// LoginResponseDTO.swift
+// Model
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+// MARK: - LoginResponse
+public typealias LoginDTOModel = BaseResponseDTO
+
+// MARK: - LoginResponseDTO
+public struct LoginResponseDTO: Decodable, Equatable {
+ let accessToken: String?
+ let accessTokenExpiresIn: Int?
+ let refreshToken: String?
+ let refreshTokenExpiresIn: Int?
+ let map: Map?
+ let socialType: String
+ let newUser: Bool?
+ let userInfo: UserInfo?
+
+ public init(
+ accessToken: String? = nil,
+ accessTokenExpiresIn: Int? = nil,
+ refreshToken: String? = nil,
+ refreshTokenExpiresIn: Int? = nil,
+ map: Map? = nil,
+ socialType: String,
+ newUser: Bool? = nil,
+ userInfo: UserInfo? = nil
+ ) {
+ self.accessToken = accessToken
+ self.accessTokenExpiresIn = accessTokenExpiresIn
+ self.refreshToken = refreshToken
+ self.refreshTokenExpiresIn = refreshTokenExpiresIn
+ self.map = map
+ self.socialType = socialType
+ self.newUser = newUser
+ self.userInfo = userInfo
+ }
+
+}
+
+// MARK: - Map
+public struct Map: Decodable, Equatable {
+ let mapName: String
+ let mapURLScheme: String?
+
+ enum CodingKeys: String, CodingKey {
+ case mapName
+ case mapURLScheme = "mapUrlScheme"
+ }
+
+ public init(mapName: String, mapURLScheme: String? = nil) {
+ self.mapName = mapName
+ self.mapURLScheme = mapURLScheme
+ }
+}
+
+// MARK: - UserInfo
+public struct UserInfo: Decodable, Equatable {
+ let email: String
+ let nickname: String?
+
+
+ public init(
+ email: String,
+ nickname: String?
+ ) {
+ self.email = email
+ self.nickname = nickname
+ }
+}
diff --git a/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift b/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift
new file mode 100644
index 0000000..f934bc3
--- /dev/null
+++ b/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift
@@ -0,0 +1,32 @@
+//
+// Extension+LoginModel.swift
+// Model
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import Entity
+
+public extension LoginResponseDTO {
+ func toDomain() -> LoginEntity {
+ let token = AuthTokens(
+ accessToken: self.accessToken ?? "",
+ refreshToken: self.refreshToken ?? "",
+ )
+
+ let provider: SocialType = switch self.socialType {
+ case "GOOGLE": .google
+ case "APPLE": .apple
+ default: .apple
+ }
+
+ return LoginEntity(
+ name: self.userInfo?.nickname ?? "",
+ isNewUser: self.newUser ?? false,
+ provider: provider,
+ token: token,
+ email: self.userInfo?.email ?? ""
+ )
+ }
+}
diff --git a/Projects/Data/Model/Sources/Base/BaseResponseDTO.swift b/Projects/Data/Model/Sources/Base/BaseResponseDTO.swift
new file mode 100644
index 0000000..3f40811
--- /dev/null
+++ b/Projects/Data/Model/Sources/Base/BaseResponseDTO.swift
@@ -0,0 +1,27 @@
+//
+// BaseResponseDTO.swift
+// Model
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+// MARK: - BaseDTO
+public struct BaseResponseDTO: Decodable {
+ public let code: Int
+ public let message: String
+ public var data: DataDTO
+
+ public init(
+ code: Int,
+ message: String,
+ data: DataDTO
+ ) {
+ self.code = code
+ self.message = message
+ self.data = data
+ }
+}
+
+extension BaseResponseDTO: Equatable where DataDTO: Equatable {}
diff --git a/Projects/Data/Model/Sources/Token/DTO/TokenDTO.swift b/Projects/Data/Model/Sources/Token/DTO/TokenDTO.swift
new file mode 100644
index 0000000..331ba36
--- /dev/null
+++ b/Projects/Data/Model/Sources/Token/DTO/TokenDTO.swift
@@ -0,0 +1,31 @@
+//
+// TokenDTO.swift
+// Model
+//
+// Created by Wonji Suh on 3/24/26.
+//
+
+import Foundation
+
+public typealias TokenDTO = BaseResponseDTO
+
+// MARK: - DataClass
+public struct TokenResponseDTO: Decodable, Equatable {
+ let accessToken: String
+ let accessTokenExpiresIn: Int
+ let refreshToken: String
+ let refreshTokenExpiresIn: Int
+
+ public init(
+ accessToken: String,
+ accessTokenExpiresIn: Int,
+ refreshToken: String,
+ refreshTokenExpiresIn: Int
+ ) {
+ self.accessToken = accessToken
+ self.accessTokenExpiresIn = accessTokenExpiresIn
+ self.refreshToken = refreshToken
+ self.refreshTokenExpiresIn = refreshTokenExpiresIn
+ }
+}
+
diff --git a/Projects/Data/Model/Sources/Token/Mapper/Extension+TokenDTO.swift b/Projects/Data/Model/Sources/Token/Mapper/Extension+TokenDTO.swift
new file mode 100644
index 0000000..95bbdb6
--- /dev/null
+++ b/Projects/Data/Model/Sources/Token/Mapper/Extension+TokenDTO.swift
@@ -0,0 +1,20 @@
+//
+// Extension+TokenDTO.swift
+// Model
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import Entity
+
+public extension TokenResponseDTO {
+ func toDomain() -> AuthTokens {
+ return AuthTokens(
+ accessToken: self.accessToken,
+ refreshToken: self.refreshToken
+ )
+
+
+ }
+}
diff --git a/Projects/Data/Repository/Project.swift b/Projects/Data/Repository/Project.swift
index 60b3216..12aba87 100644
--- a/Projects/Data/Repository/Project.swift
+++ b/Projects/Data/Repository/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Repository",
bundleId: .appBundleID(name: ".Repository"),
product: .staticFramework,
diff --git a/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift b/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift
index a252ff3..06a068e 100644
--- a/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift
+++ b/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift
@@ -77,7 +77,7 @@ public final class DirectionRepositoryImpl: DirectionInterface, @unchecked Senda
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: start))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination))
- request.transportType = .walking // 🚶♂️ 도보 모드
+ request.transportType = .walking // ♂️ 도보 모드
let directions = MKDirections(request: request)
diff --git a/Projects/Data/Repository/Sources/OAuth/Apple/AppleLoginRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Apple/AppleLoginRepositoryImpl.swift
new file mode 100644
index 0000000..ea36737
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Apple/AppleLoginRepositoryImpl.swift
@@ -0,0 +1,71 @@
+//
+// AppleLoginRepositoryImpl.swift
+// Repository
+//
+// Created by Wonji Suh on 12/26/25.
+//
+
+
+import Foundation
+
+import AuthenticationServices
+
+import DomainInterface
+import CryptoKit
+
+public struct AppleLoginRepositoryImpl: AppleAuthRequestInterface {
+
+ public init() {}
+
+ public func prepare(_ request: ASAuthorizationAppleIDRequest) -> String {
+ let nonce = randomNonceString()
+ request.requestedScopes = [.email, .fullName]
+ request.nonce = sha256(nonce)
+ return nonce
+ }
+
+ public func sha256(_ input: String) -> String {
+ let inputData = Data(input.utf8)
+ let hashedData = SHA256.hash(data: inputData)
+ let hashString = hashedData.compactMap {
+ String(format: "%02x", $0)
+ }.joined()
+
+ return hashString
+ }
+
+ public func randomNonceString(length: Int = 32) -> String {
+ precondition(length > 0)
+ let charset: [Character] =
+ Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
+ var result = ""
+ var remainingLength = length
+
+ while remainingLength > 0 {
+ let randoms: [UInt8] = (0 ..< 16).map { _ in
+ var random: UInt8 = 0
+ let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
+ if errorCode != errSecSuccess {
+ fatalError(
+ "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)"
+ )
+ }
+ return random
+ }
+
+ randoms.forEach { random in
+ if remainingLength == 0 {
+ return
+ }
+
+ if random < charset.count {
+ result.append(charset[Int(random)])
+ remainingLength -= 1
+ }
+ }
+ }
+
+ return result
+ }
+}
+
diff --git a/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift
new file mode 100644
index 0000000..e33c03b
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift
@@ -0,0 +1,156 @@
+//
+// AppleOAuthRepositoryImpl.swift
+// Repository
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+import AuthenticationServices
+
+import DomainInterface
+@preconcurrency import Entity
+
+import LogMacro
+import WeaveDI
+import ComposableArchitecture
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+public final class AppleOAuthRepositoryImpl: NSObject, AppleOAuthInterface, @unchecked Sendable {
+ private let logger = LogMacro.Log.self
+ @Dependency(\.appleManger) var appleLoginManger
+ @Shared(.appStorage("appleUserName")) var appleUserName: String?
+
+ private var currentNonce: String?
+ private var signInContinuation: CheckedContinuation?
+ private var isSigningIn: Bool = false
+
+ public override init() {
+
+ }
+ public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload {
+ // 받은 credential으로 직접 payload 생성
+ guard let identityTokenData = credential.identityToken,
+ let identityToken = String(data: identityTokenData, encoding: .utf8)
+ else {
+ throw AuthError.missingIDToken
+ }
+
+ let authorizationCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) }
+ let displayName = formatDisplayName(credential.fullName)
+
+ return AppleOAuthPayload(
+ idToken: identityToken,
+ authorizationCode: authorizationCode,
+ displayName: displayName,
+ nonce: nonce
+ )
+ }
+
+ @MainActor
+ public func signIn() async throws -> AppleOAuthPayload {
+ // 이미 진행 중인 로그인이 있으면 기다림
+ if isSigningIn {
+ return try await withCheckedThrowingContinuation { newContinuation in
+ newContinuation.resume(throwing: AuthError.invalidCredential("이미 로그인이 진행 중입니다"))
+ }
+ }
+
+ return try await withCheckedThrowingContinuation { continuation in
+ self.isSigningIn = true
+ self.signInContinuation = continuation
+
+ let request = ASAuthorizationAppleIDProvider().createRequest()
+ let nonce = appleLoginManger.prepare(request)
+ self.currentNonce = nonce
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performRequests()
+ }
+ }
+
+ private func formatDisplayName(_ components: PersonNameComponents?) -> String? {
+ guard let components else { return nil }
+ let formatter = PersonNameComponentsFormatter()
+ let name = formatter.string(from: components).trimmingCharacters(in: .whitespacesAndNewlines)
+ return name.isEmpty ? nil : name
+ }
+}
+
+// MARK: - ASAuthorizationControllerDelegate
+extension AppleOAuthRepositoryImpl: ASAuthorizationControllerDelegate {
+ public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
+ guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
+ signInContinuation?.resume(throwing: AuthError.invalidCredential("Invalid credential type"))
+ signInContinuation = nil
+ return
+ }
+
+ guard let nonce = currentNonce else {
+ signInContinuation?.resume(throwing: AuthError.missingIDToken)
+ signInContinuation = nil
+ return
+ }
+
+ guard let identityTokenData = credential.identityToken,
+ let identityToken = String(data: identityTokenData, encoding: .utf8) else {
+ signInContinuation?.resume(throwing: AuthError.missingIDToken)
+ signInContinuation = nil
+ return
+ }
+
+ let displayName = formatDisplayName(credential.fullName)
+ let authorizationCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) }
+
+ let payload = AppleOAuthPayload(
+ idToken: identityToken,
+ authorizationCode: authorizationCode,
+ displayName: displayName,
+ nonce: nonce
+ )
+
+ self.$appleUserName.withLock { $0 = displayName }
+
+ logger.info("Apple Sign In successful for user: \(displayName ?? "unknown"), \(appleUserName)")
+ signInContinuation?.resume(returning: payload)
+ signInContinuation = nil
+ currentNonce = nil
+ isSigningIn = false
+ }
+
+ public func authorizationController(
+ controller: ASAuthorizationController,
+ didCompleteWithError error: Error
+ ) {
+ let nsError = error as NSError
+
+ if nsError.code == ASAuthorizationError.canceled.rawValue {
+ signInContinuation?.resume(throwing: AuthError.userCancelled)
+ } else {
+ logger.error("Apple Sign In failed: \(error.localizedDescription)")
+ signInContinuation?.resume(throwing: AuthError.invalidCredential(error.localizedDescription))
+ }
+
+ signInContinuation = nil
+ currentNonce = nil
+ isSigningIn = false
+ }
+}
+
+// MARK: - ASAuthorizationControllerPresentationContextProviding
+extension AppleOAuthRepositoryImpl: ASAuthorizationControllerPresentationContextProviding {
+ public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
+#if canImport(UIKit)
+ return UIApplication.shared.connectedScenes
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
+ .first ?? ASPresentationAnchor()
+#else
+ return ASPresentationAnchor()
+#endif
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift
new file mode 100644
index 0000000..8f0b5d0
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift
@@ -0,0 +1,229 @@
+//
+// AuthInterceptor.swift
+// Repository
+//
+// Created by Wonji Suh on 1/8/26.
+//
+
+import Foundation
+import Alamofire
+import DomainInterface
+import Entity
+import Dependencies
+import Moya
+import Combine
+import LogMacro
+import ComposableArchitecture
+import UseCase
+
+// 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 {
+ // 이미 갱신 중인 요청이 있다면 양보 후 재시도
+ if isRefreshing {
+ // 다른 작업에 양보하고 재시도
+ await _Concurrency.Task.yield()
+ return try await refreshCredentialIfNeeded()
+ }
+
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ #logDebug("🔄 Starting token refresh...")
+
+ do {
+ let tokens = try await authRepository.refresh()
+ #logDebug("✅ Token refresh completed successfully: \(tokens)")
+
+ // 키체인에 새 토큰 저장
+ try await keychainManager.save(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
+
+ // AuthSessionManager에 새 credential 업데이트
+ let newCredential = AccessTokenCredential.make(
+ accessToken: "",
+ refreshToken: ""
+ )
+
+ // 메인 스레드에서 세션 매니저 업데이트
+ await MainActor.run {
+ AuthSessionManager.shared.credential = newCredential
+ }
+
+ return newCredential
+ } catch {
+ #logDebug("❌ Token refresh failed: \(error)")
+
+ // Refresh token이 만료된 경우 자동 로그아웃 수행
+ if isRefreshTokenExpiredError(error) {
+ #logDebug("🚪 [TokenRefreshManager] 401 ERROR DETECTED! Starting automatic logout...")
+ try await performAutomaticLogout()
+ #logDebug("✅ [TokenRefreshManager] Automatic logout completed, throwing refresh token expired error")
+ throw AuthError.refreshTokenExpired
+ } else {
+ #logDebug("⚠️ [TokenRefreshManager] Non-401 error, rethrowing: \(error)")
+ throw error
+ }
+ }
+ }
+
+ /// Refresh token이 만료된 에러인지 확인
+ private func isRefreshTokenExpiredError(_ error: Error) -> Bool {
+ #logDebug("🔍 [TokenRefreshManager] 🚨 CHECKING IF 401 ERROR: \(error)")
+
+ // 1. statusCodeError(401) 직접 감지 (최우선)
+ let errorString = String(describing: error)
+ if errorString.contains("statusCodeError(401)") {
+ #logDebug("🎯 [TokenRefreshManager] ✅ statusCodeError(401) DETECTED!")
+ return true
+ }
+
+ // 2. Moya의 MoyaError를 통해 HTTP 상태 코드 확인
+ if let moyaError = error as? MoyaError {
+ switch moyaError {
+ case .statusCode(let response):
+ #logDebug("📋 [TokenRefreshManager] MoyaError statusCode: \(response.statusCode)")
+ if response.statusCode == 401 {
+ #logDebug("🎯 [TokenRefreshManager] ✅ MoyaError 401 DETECTED!")
+ return true
+ }
+ case .underlying(_, let response):
+ #logDebug("📋 [TokenRefreshManager] MoyaError underlying statusCode: \(String(describing: response?.statusCode))")
+ if response?.statusCode == 401 {
+ #logDebug("🎯 [TokenRefreshManager] ✅ MoyaError underlying 401 DETECTED!")
+ return true
+ }
+ default:
+ #logDebug("📋 [TokenRefreshManager] Other MoyaError: \(moyaError)")
+ }
+ }
+
+ // 3. AuthError인 경우
+ if let authError = error as? AuthError {
+ #logDebug("📋 [TokenRefreshManager] AuthError: \(authError)")
+ if authError.isTokenExpiredError {
+ #logDebug("🎯 [TokenRefreshManager] ✅ AuthError TOKEN EXPIRED DETECTED!")
+ return true
+ }
+ }
+
+ // 4. 에러 메시지에서 401 키워드 확인 (더 포괄적으로)
+ let errorDesc = error.localizedDescription.lowercased()
+ if errorDesc.contains("401") ||
+ errorDesc.contains("unauthorized") ||
+ errorDesc.contains("유효하지 않은 토큰") ||
+ errorDesc.contains("invalid token") ||
+ errorDesc.contains("token expired") ||
+ errorDesc.contains("authentication failed") {
+ #logDebug("🎯 [TokenRefreshManager] ✅ ERROR MESSAGE 401 DETECTED: \(errorDesc)")
+ return true
+ }
+
+ #logDebug("❌ [TokenRefreshManager] Error is NOT 401 - continuing normally")
+ return false
+ }
+
+ /// 자동 로그아웃 수행 (로컬 상태 정리만)
+ private func performAutomaticLogout() async throws {
+ #logDebug("🚪 [TokenRefreshManager] 🔥 PERFORMING AUTOMATIC LOGOUT - 401 ERROR DETECTED!")
+
+ // Refresh token이 만료된 상황이므로 서버 API 호출은 불가능
+ // 로컬 상태만 정리함
+
+ // 1. Keychain에서 모든 토큰 제거
+ #logDebug("🔑 [TokenRefreshManager] Clearing keychain tokens...")
+ try await keychainManager.clear()
+ #logDebug("✅ [TokenRefreshManager] Keychain cleared")
+
+ // 2. AuthSessionManager credential 정리
+ #logDebug("🗂️ [TokenRefreshManager] Clearing session manager...")
+ await MainActor.run {
+ AuthSessionManager.shared.credential = nil
+ }
+ #logDebug("✅ [TokenRefreshManager] Session manager cleared")
+
+ // 3. 전역 로그인 만료 알림 전송 - 확실하게 발송
+ #logDebug("📢 [TokenRefreshManager] 🚨 SENDING LOGOUT NOTIFICATION...")
+ await MainActor.run {
+ NotificationCenter.default.post(
+ name: NSNotification.Name("RefreshTokenExpired"),
+ object: nil,
+ userInfo: ["reason": "401_refresh_failed"] // 추가 정보
+ )
+ #logDebug("✅ [TokenRefreshManager] 🎯 RefreshTokenExpired NOTIFICATION SENT!")
+ }
+
+ #logDebug("✅ [TokenRefreshManager] 🔥 AUTOMATIC LOGOUT COMPLETED!")
+ }
+}
+
+// MARK: - Auth Interceptor
+final class AuthInterceptor: RequestInterceptor, @unchecked Sendable {
+ private let tokenRefreshManager = TokenRefreshManager()
+
+ func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) {
+ var adaptedRequest = urlRequest
+
+ // AuthSessionManager에서 현재 credential 가져오기
+ guard let credential = AuthSessionManager.shared.credential else {
+ // 토큰이 없으면 원본 요청 그대로 전달
+ completion(.success(urlRequest))
+ return
+ }
+
+ // 토큰이 곧 만료되는지 확인
+ if credential.requiresRefresh {
+ _Concurrency.Task {
+ do {
+ // 토큰 갱신 (동시성 안전하게 처리됨)
+ let newCredential = try await tokenRefreshManager.refreshCredentialIfNeeded()
+ adaptedRequest.headers.update(.authorization(bearerToken: newCredential.accessToken))
+ completion(.success(adaptedRequest))
+ } catch {
+ #logDebug("❌ Token refresh failed in adapt: \(error)")
+ completion(.failure(error))
+ }
+ }
+ } else {
+ // 토큰이 아직 유효하면 그대로 사용
+ adaptedRequest.headers.update(.authorization(bearerToken: credential.accessToken))
+ completion(.success(adaptedRequest))
+ }
+ }
+
+ func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
+ // 401 Unauthorized 에러가 아니면 재시도하지 않음
+ guard let response = request.response, response.statusCode == 401 else {
+ completion(.doNotRetryWithError(error))
+ return
+ }
+
+ #logDebug("🚨 401 Unauthorized detected, attempting token refresh for retry")
+
+ _Concurrency.Task {
+ do {
+ // 토큰 갱신 시도
+ _ = try await tokenRefreshManager.refreshCredentialIfNeeded()
+ // 갱신 성공 시 원래 요청 재시도
+ completion(.retry)
+ } catch {
+ #logDebug("❌ Token refresh failed in retry: \(error)")
+
+ // Refresh token이 만료된 경우 특별 처리
+ if let authError = error as? AuthError, authError.isTokenExpiredError {
+ #logDebug("🚪 Refresh token expired in retry - user will be automatically logged out")
+ // 자동 로그아웃이 이미 TokenRefreshManager에서 수행되었으므로
+ // 단순히 에러를 전달하여 UI가 적절히 대응할 수 있도록 함
+ completion(.doNotRetryWithError(authError))
+ } else {
+ // 갱신 실패 시 재시도하지 않고 에러 전달
+ completion(.doNotRetryWithError(error))
+ }
+ }
+ }
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift
new file mode 100644
index 0000000..208988b
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift
@@ -0,0 +1,82 @@
+//
+// AccessTokenCredential.swift
+// Repository
+//
+// Created by Wonji Suh on 1/2/26.
+//
+
+import Foundation
+import Alamofire
+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
+ }
+
+ static func make(
+ accessToken: String,
+ refreshToken: String
+ ) -> AccessTokenCredential {
+ // JWT 디코딩을 시도하되, 실패하면 기본 만료시간 사용 (24시간 후)
+ let fallbackExpiration = Date().addingTimeInterval(24 * 60 * 60) // 24시간
+ let expiration = decodeExpiration(from: accessToken) ?? {
+ #logDebug("⚠️ JWT decoding failed, using fallback expiration: 24 hours from now")
+ return 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 {
+ #logDebug("🚫 JWT decoding failed: Invalid JWT format (expected 3 parts, got \(components.count))")
+ 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) else {
+ #logDebug("🚫 JWT decoding failed: Base64 decoding failed")
+ return nil
+ }
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ #logDebug("🚫 JWT decoding failed: JSON parsing failed")
+ return nil
+ }
+
+ guard let exp = json["exp"] as? TimeInterval else {
+ #logDebug("🚫 JWT decoding failed: 'exp' claim not found or invalid type")
+ #logDebug("🔍 Available keys in JWT payload: \(json.keys.joined(separator: ", "))")
+ return nil
+ }
+
+ let expirationDate = Date(timeIntervalSince1970: exp)
+ #logDebug("✅ JWT expiration decoded successfully: \(expirationDate)")
+ #logDebug("🕐 Time until expiration: \(expirationDate.timeIntervalSinceNow / 3600) hours")
+
+ return expirationDate
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AuthSessionManager.swift b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AuthSessionManager.swift
new file mode 100644
index 0000000..0d938d5
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AuthSessionManager.swift
@@ -0,0 +1,72 @@
+//
+// AuthSessionManager.swift
+// Repository
+//
+// Created by Wonji Suh on 1/2/26.
+//
+
+import Foundation
+import Alamofire
+import DomainInterface
+import Entity
+import WeaveDI
+
+final class AuthSessionManager {
+ static let shared = AuthSessionManager()
+
+ @Dependency(\.keychainManager) var keychainManager
+
+ // 인터셉터가 직접 크리덴셜을 관리하지 않으므로, SessionManager가 크리덴셜을 소유하고 관리합니다.
+ var credential: AccessTokenCredential?
+
+ let session: Session
+
+ private init() {
+ // AuthInterceptor를 세션의 인터셉터로 직접 사용합니다.
+ self.session = Session(interceptor: AuthInterceptor())
+ Task { [weak self] in
+ await self?.setupInitialCredential()
+ }
+ }
+
+ func updateCredential(with tokens: AuthTokens) {
+ let newCredential = AccessTokenCredential.make(
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken
+ )
+ self.credential = newCredential
+ }
+
+ func clear() {
+ self.credential = nil
+ // Keychain에서도 삭제가 필요하다면 여기에 로직 추가
+ // keychainManager.deleteTokens()
+ }
+}
+
+private extension AuthSessionManager {
+ func setupInitialCredential() async {
+ if let loadedCredential = await loadCredentialFromKeychain() {
+ self.credential = loadedCredential
+ }
+ }
+
+ func loadCredentialFromKeychain() async -> AccessTokenCredential? {
+ let accessToken = await keychainManager.accessToken()
+ let refreshToken = await keychainManager.refreshToken()
+
+ guard
+ let accessToken = accessToken,
+ let refreshToken = refreshToken,
+ !accessToken.isEmpty,
+ !refreshToken.isEmpty
+ else {
+ return nil
+ }
+
+ return AccessTokenCredential.make(
+ accessToken: accessToken,
+ refreshToken: refreshToken
+ )
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift
new file mode 100644
index 0000000..e638caa
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift
@@ -0,0 +1,21 @@
+//
+// Extension+MoyaProvider+Auth.swift
+// Repository
+//
+// Created by Wonji Suh on 1/2/26.
+//
+
+import AsyncMoya
+
+public extension MoyaProvider {
+ static var authorized: MoyaProvider {
+ let manager = AuthSessionManager.shared
+
+ return MoyaProvider(
+ session: manager.session,
+ plugins: [
+ MoyaLoggingPlugin()
+ ]
+ )
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/Extension+MoyaProvider+Response.swift b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/Extension+MoyaProvider+Response.swift
new file mode 100644
index 0000000..2570f42
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/Extension+MoyaProvider+Response.swift
@@ -0,0 +1,21 @@
+//
+// Extension+MoyaProvider+Response.swift
+// Repository
+//
+// Created by Wonji Suh on 1/2/26.
+//
+
+import Foundation
+
+import AsyncMoya
+import Moya
+
+public extension MoyaProvider {
+ func requestResponse(_ target: Target) async throws -> Response {
+ try await withCheckedThrowingContinuation { continuation in
+ request(target) { result in
+ continuation.resume(with: result)
+ }
+ }
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift
new file mode 100644
index 0000000..ceefa85
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift
@@ -0,0 +1,155 @@
+//
+// AuthRepositoryImpl.swift
+// Repository
+//
+// Created by Wonji Suh on 7/23/25.
+//
+
+import DomainInterface
+import Model
+import Entity
+
+import Service
+import WeaveDI
+import Dependencies
+import Moya
+import LogMacro
+
+import AsyncMoya
+
+final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable {
+ @Dependency(\.keychainManager) private var keychainManager
+ private let provider: MoyaProvider
+ private let authProvider: MoyaProvider
+
+ public init(
+ provider: MoyaProvider = MoyaProvider.default,
+ authProvider: MoyaProvider = MoyaProvider.authorized
+ ) {
+ self.provider = provider
+ self.authProvider = authProvider
+ }
+
+
+ // MARK: - 로그인 API
+ public func login(
+ provider socialProvider: SocialType,
+ token: String
+ ) async throws -> LoginEntity {
+ let reqeust = OAuthLoginRequest(provider: socialProvider.rawValue, idToken: token)
+ let dto: LoginDTOModel = try await provider.request(.login(body: reqeust))
+ return dto.data.toDomain()
+ }
+
+// public func login(
+// provider socialProvider: SocialType,
+// token: String
+// ) async throws -> LoginEntity {
+// let dto: LoginResponseDTO = try await provider.request(
+// .login(body: OAuthLoginRequest(provider: socialProvider.description, token: token))
+// )
+// return dto.toDomain()
+// }
+//
+//
+// // MARK: - 토큰 재발급
+ public func refresh() async throws -> AuthTokens {
+ let refreshToken = await keychainManager.refreshToken() ?? ""
+
+ do {
+ // Use non-authorized provider to avoid interceptor recursion on refresh.
+ let dto: TokenDTO = try await provider.request(.refresh(refreshToken: refreshToken))
+ let refreshData = dto.data.toDomain()
+
+ // ✅ TokenRefresher에서 keychain 저장과 credential 업데이트를 담당하므로 중복 제거
+ return refreshData
+ } catch {
+ #logDebug("🔍 [AuthRepositoryImpl] Refresh failed: \(error)")
+
+ // 401 에러 감지 및 처리는 AuthInterceptor에서 처리하므로 여기서는 단순히 에러 전달
+ // AuthInterceptor가 더 정확하고 포괄적인 401 에러 감지를 수행
+ let errorString = String(describing: error)
+ if errorString.contains("statusCodeError(401)") {
+ #logDebug("🚪 [AuthRepositoryImpl] statusCodeError(401) detected - AuthInterceptor will handle logout")
+ throw AuthError.refreshTokenExpired
+ }
+
+ // MoyaError 401 체크
+ if let moyaError = error as? MoyaError {
+ switch moyaError {
+ case .statusCode(let response) where response.statusCode == 401:
+ #logDebug("🚪 [AuthRepositoryImpl] MoyaError statusCode 401 detected - AuthInterceptor will handle logout")
+ throw AuthError.refreshTokenExpired
+ case .underlying(_, let response) where response?.statusCode == 401:
+ #logDebug("🚪 [AuthRepositoryImpl] MoyaError underlying 401 detected - AuthInterceptor will handle logout")
+ throw AuthError.refreshTokenExpired
+ default:
+ break
+ }
+ }
+
+ // 에러 메시지에서 401 키워드 체크
+ let errorDesc = error.localizedDescription.lowercased()
+ if errorDesc.contains("401") || errorDesc.contains("유효하지 않은 토큰") {
+ #logDebug("🚪 [AuthRepositoryImpl] Error description contains 401/invalid token - AuthInterceptor will handle logout")
+ throw AuthError.refreshTokenExpired
+ }
+
+ throw error
+ }
+ }
+
+ // MARK: - 로그아웃
+// public func logout() async throws -> AuthExitEntity {
+// let response = try await authProvider.requestResponse(.logout)
+// let decoder = JSONDecoder()
+//
+// if (200...299).contains(response.statusCode) {
+// keychainManager.clear()
+// if response.data.isEmpty {
+// return AuthExitEntity()
+// }
+// if let successDTO = try? decoder.decode(LogOutDTO.self, from: response.data) {
+// return successDTO.toDomain()
+// }
+// return AuthExitEntity()
+// }
+//
+// if let errorDTO = try? decoder.decode(LogOutDTO.self, from: response.data) {
+// return errorDTO.toDomain()
+// }
+//
+// let errorMessage = String(data: response.data, encoding: .utf8)
+// return AuthExitEntity(message: errorMessage)
+// }
+//
+// // MARK: - 계정 삭제
+// public func withDraw(token: String) async throws -> WithdrawEntity {
+// let response = try await provider.requestResponse(.withdraw(token: token))
+// let decoder = JSONDecoder()
+//
+// if (200...299).contains(response.statusCode) {
+// if response.data.isEmpty {
+// return WithdrawEntity(isSuccess: true)
+// }
+// if let successDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) {
+// return successDTO.toDomain(isSuccess: true)
+// }
+// return WithdrawEntity(isSuccess: true)
+// }
+//
+// if let errorDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) {
+// return errorDTO.toDomain(isSuccess: false)
+// }
+// return WithdrawEntity(
+// isSuccess: false,
+// message: String(data: response.data, encoding: .utf8)
+// )
+// }
+
+ // MARK: - 세션 Credential 업데이트
+ public func updateSessionCredential(with tokens: AuthTokens) {
+ AuthSessionManager.shared.updateCredential(with: tokens)
+ }
+
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Google/GoogleLoginManager.swift b/Projects/Data/Repository/Sources/OAuth/Google/GoogleLoginManager.swift
new file mode 100644
index 0000000..cd6208e
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Google/GoogleLoginManager.swift
@@ -0,0 +1,23 @@
+//
+// GoogleLoginManager.swift
+// Repository
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import CryptoKit
+import SwiftUI
+
+struct GoogleLoginManager {
+ static let shared = GoogleLoginManager()
+
+ func getRootViewController()->UIViewController{
+ guard let screen = UIApplication.shared.connectedScenes.first as? UIWindowScene else{
+ return .init()
+ }
+ guard let root = screen.windows.first?.rootViewController else{
+ return .init()
+ }
+ return root
+ }
+}
diff --git a/Projects/Data/Repository/Sources/OAuth/Google/GoogleOAuthConfiguration.swift b/Projects/Data/Repository/Sources/OAuth/Google/GoogleOAuthConfiguration.swift
new file mode 100644
index 0000000..4977645
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Google/GoogleOAuthConfiguration.swift
@@ -0,0 +1,30 @@
+//
+// GoogleOAuthConfiguration.swift
+// Repository
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+public struct GoogleOAuthConfiguration: Sendable {
+ public let clientID: String
+ public let serverClientID: String
+
+ public static var current: GoogleOAuthConfiguration {
+ let clientID = "\(Bundle.main.object(forInfoDictionaryKey: "GOOGLE_IOS_CLIENT_ID") as? String ?? "")"
+ let serverClientID = "\(Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_ID") as? String ?? "")"
+ return GoogleOAuthConfiguration(clientID: clientID, serverClientID: serverClientID)
+ }
+
+ public var isValid: Bool {
+ !clientID.contains("YOUR_GOOGLE_IOS_CLIENT_ID") &&
+ !serverClientID.contains("GOOGLE_CLIENT_ID")
+ }
+
+ public init(clientID: String, serverClientID: String) {
+ self.clientID = clientID
+ self.serverClientID = serverClientID
+ }
+}
+
diff --git a/Projects/Data/Repository/Sources/OAuth/Google/GoogleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Google/GoogleOAuthRepositoryImpl.swift
new file mode 100644
index 0000000..ef53d98
--- /dev/null
+++ b/Projects/Data/Repository/Sources/OAuth/Google/GoogleOAuthRepositoryImpl.swift
@@ -0,0 +1,99 @@
+//
+// GoogleOAuthRepositoryImpl.swift
+// Repository
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import DomainInterface
+import Entity
+
+import GoogleSignIn
+import LogMacro
+
+public final class GoogleOAuthRepositoryImpl: GoogleOAuthInterface, Sendable {
+ private let configuration: GoogleOAuthConfiguration
+
+ public init(configuration: GoogleOAuthConfiguration = .current) {
+ self.configuration = configuration
+ }
+
+ @MainActor
+ public func signIn() async throws -> GoogleOAuthPayload {
+ guard configuration.isValid else {
+ throw AuthError.configurationMissing
+ }
+ guard let presenting = Self.topViewController() else {
+ throw AuthError.missingPresentingController
+ }
+ let gidConfiguration = GIDConfiguration(
+ clientID: configuration.clientID,
+ serverClientID: configuration.serverClientID
+ )
+ GIDSignIn.sharedInstance.configuration = gidConfiguration
+
+ do {
+ let result = try await signInWithRetry(presenting: presenting)
+ return try makePayload(from: result)
+ } catch let error as NSError {
+ throw mapSignInError(error)
+ }
+ }
+
+ @MainActor
+ private func signInWithRetry(
+ presenting: UIViewController
+ ) async throws -> GIDSignInResult {
+ do {
+ return try await GIDSignIn.sharedInstance.signIn(withPresenting: presenting)
+ } catch let error as NSError where error.domain == "RBSServiceErrorDomain" && error.code == 1 {
+ Log.error("RBSServiceErrorDomain detected, retrying Google sign-in...")
+ try await Task.sleep(for: .seconds(0.5))
+ return try await GIDSignIn.sharedInstance.signIn(withPresenting: presenting)
+ }
+ }
+
+ private func makePayload(from result: GIDSignInResult) throws -> GoogleOAuthPayload {
+ guard let idToken = result.user.idToken?.tokenString else {
+ throw AuthError.missingIDToken
+ }
+
+ let payload = GoogleOAuthPayload(
+ idToken: idToken,
+ accessToken: result.user.refreshToken.tokenString,
+ authorizationCode: result.serverAuthCode,
+ displayName: result.user.profile?.name
+ )
+
+ Log.info("Google serverAuthCode present: \(payload.authorizationCode != nil ? "yes" : "no") , \(payload.authorizationCode)")
+ return payload
+ }
+
+ private func mapSignInError(_ error: NSError) -> Error {
+ if error.domain == "com.google.GIDSignIn",
+ error.code == GIDSignInError.canceled.rawValue {
+ Log.info("Google sign-in cancelled by user.")
+ return AuthError.userCancelled
+ }
+
+ Log.error("Google sign-in failed: \(error.localizedDescription)")
+ return AuthError.unknownError(error.localizedDescription)
+ }
+
+ private static func topViewController(
+ base: UIViewController? = UIApplication.shared.connectedScenes
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
+ .first?.rootViewController
+ ) -> UIViewController? {
+ if let nav = base as? UINavigationController {
+ return topViewController(base: nav.visibleViewController)
+ }
+ if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
+ return topViewController(base: selected)
+ }
+ if let presented = base?.presentedViewController {
+ return topViewController(base: presented)
+ }
+ return base
+ }
+}
diff --git a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift
new file mode 100644
index 0000000..33a71ee
--- /dev/null
+++ b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift
@@ -0,0 +1,38 @@
+//
+// SignUpRepositoryImpl.swift
+// Repository
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+
+import DomainInterface
+import Model
+import Entity
+import Service
+
+@preconcurrency import AsyncMoya
+
+final public class SignUpRepositoryImpl: SignUpInterface {
+
+ private let provider: MoyaProvider
+
+ public init(
+ provider: MoyaProvider = MoyaProvider.default
+ ) {
+ self.provider = provider
+ }
+
+ // MARK: - 회원가입
+ public func registerUser(input: SignUpInput) async throws -> LoginEntity {
+ let body = SignUpRequestDTO(
+ provider: input.provider.rawValue,
+ authCode: input.authCode,
+ nickname: input.name,
+ email: input.email,
+ mapApi: input.mapType.type
+ )
+ let dto: LoginDTOModel = try await provider.request(.signUp(body: body))
+ return dto.data.toDomain()
+ }
+}
diff --git a/Projects/Data/Repository/Tests/Sources/DirectionRepositoryImplTests.swift b/Projects/Data/Repository/Tests/Sources/DirectionRepositoryImplTests.swift
new file mode 100644
index 0000000..f54aa72
--- /dev/null
+++ b/Projects/Data/Repository/Tests/Sources/DirectionRepositoryImplTests.swift
@@ -0,0 +1,214 @@
+//
+// DirectionRepositoryImplTests.swift
+// RepositoryTests
+//
+// Created by Wonji Suh on 2026-03-12
+// Copyright © 2026 TimeSpot, Ltd., All rights reserved.
+//
+
+import Testing
+import Foundation
+import CoreLocation
+import MapKit
+import Dependencies
+@testable import Repository
+@testable import Entity
+@testable import DomainInterface
+
+@Suite("DirectionRepository Mock 테스트", .tags(.unit, .repository, .route))
+struct DirectionRepositoryImplTests {
+
+ // MARK: - Test Data
+ let testStartCoord = CLLocationCoordinate2D(latitude: 37.497942, longitude: 127.027621) // 강남역
+ let testDestCoord = CLLocationCoordinate2D(latitude: 37.556785, longitude: 126.923011) // 홍대입구역
+
+ // MARK: - Mock Repository Tests (API 호출 없음)
+
+ @Test("하이브리드 경로 조회 정상 반환 확인", .tags(.success, .hybrid))
+ func 하이브리드경로_정상반환() async throws {
+ // Given - Mock Repository를 사용 (실제 API 호출하지 않음)
+ let repository = MockDirectionRepository()
+
+ // When
+ let result = try await repository.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then
+ #expect(result.paths.count > 0)
+ #expect(result.distance > 0)
+ #expect(result.duration > 0)
+ #expect(result.tollFare == 0) // 도보는 통행료 없음
+ #expect(result.taxiFare == 0) // 도보는 택시비 없음
+ }
+
+ @Test("병렬 API 호출 성능 측정 (5초 이내 완료)", .tags(.success, .performance, .hybrid))
+ func 병렬API_성능측정() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+ let startTime = CFAbsoluteTimeGetCurrent()
+
+ // When
+ let result = try await repository.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then
+ let executionTime = CFAbsoluteTimeGetCurrent() - startTime
+ #expect(result.paths.count > 0)
+ // 병렬 호출로 5초 이내 완료 예상 (네트워크 상황 고려)
+ #expect(executionTime < 5.0)
+ }
+
+ @Test("Apple MapKit 도보 시간 합리성 확인", .tags(.success, .route))
+ func appleMapKit_도보시간_합리성() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+
+ // When
+ let result = try await repository.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then - Apple MapKit의 도보 시간은 분 단위로 합리적이어야 함
+ #expect(result.duration > 0)
+ #expect(result.duration < 300) // 5시간 미만이어야 합리적
+ }
+
+ @Test("네이버 경로 좌표 한국 범위 내 연속성 확인", .tags(.success, .route))
+ func 네이버경로_한국범위_연속성() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+
+ // When
+ let result = try await repository.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then
+ #expect(result.paths.count >= 2) // 최소 출발지-도착지
+
+ // 경로 좌표들이 한국 범위 내에 있는지 확인
+ for path in result.paths {
+ #expect(path.latitude > 33.0 && path.latitude < 39.0, "위도가 한국 범위를 벗어남")
+ #expect(path.longitude > 124.0 && path.longitude < 132.0, "경도가 한국 범위를 벗어남")
+ }
+ }
+
+ @Test("Constants 컴파일 타임 접근 가능성 확인", .tags(.success, .repository))
+ func constants_컴파일접근_확인() {
+ // Given & When & Then
+ // DirectionRepositoryImpl 인스턴스 생성이 성공하면
+ // 내부 Constants enum이 컴파일 시점에 정상 정의된 것을 의미
+ let repository = MockDirectionRepository()
+ #expect(repository != nil)
+ }
+
+ // MARK: - Error Cases
+
+ @Test("잘못된 좌표 Mock 동작 확인", .tags(.success, .route))
+ func 잘못된좌표_Mock동작() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+ let invalidCoord = CLLocationCoordinate2D(latitude: 999, longitude: 999)
+
+ // When
+ let result = try await repository.getRoute(
+ from: invalidCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then - Mock은 항상 성공 응답을 반환함 (실제 API와 다름)
+ #expect(result.distance == 1000)
+ #expect(result.duration == 15)
+ #expect(result.paths.count == 3) // start, middle, end
+ }
+
+ @Test("동일 위치 Mock 동작 확인", .tags(.success, .route))
+ func 동일위치_Mock동작() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+ let sameCoord = testStartCoord
+
+ // When
+ let result = try await repository.getRoute(
+ from: sameCoord,
+ to: sameCoord,
+ option: .walking
+ )
+
+ // Then - Mock은 같은 위치라도 고정값 반환 (실제 API와 다름)
+ #expect(result.distance == 1000) // Mock 고정값
+ #expect(result.duration == 15) // Mock 고정값
+ #expect(result.paths.count == 3) // start, middle, end
+
+ // 좌표 비교 (CLLocationCoordinate2D는 Equatable 미지원)
+ #expect(result.paths[0].latitude == sameCoord.latitude) // 시작점 위도
+ #expect(result.paths[0].longitude == sameCoord.longitude) // 시작점 경도
+ #expect(result.paths[2].latitude == sameCoord.latitude) // 끝점 위도
+ #expect(result.paths[2].longitude == sameCoord.longitude) // 끝점 경도
+ }
+
+ // MARK: - Error Handling Tests
+
+ @Test("네트워크 이슈 발생 시 DirectionError 변환 확인", .tags(.error, .repository))
+ func 네트워크이슈_에러변환() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+
+ // When & Then
+ // 실제 네트워크 차단 없이는 강제로 에러를 유발하기 어려우므로,
+ // 정상 호출 경로에서 에러 핸들링 코드가 존재하는지 컴파일 수준으로 확인
+ do {
+ _ = try await repository.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+ // 성공 시 — 에러 핸들링 브랜치는 실행되지 않지만 코드가 존재함을 의미
+ } catch let error as DirectionError {
+ // DirectionError로 변환되었다면 에러 처리 파이프라인이 올바르게 동작
+ #expect(Bool(true), "DirectionError로 변환됨: \(error.localizedDescription ?? "")")
+ } catch {
+ // 다른 타입의 에러는 DirectionError.from(_:) 변환 대상
+ #expect(Bool(true), "기타 에러 발생: \(error)")
+ }
+ }
+
+ // MARK: - Hybrid Architecture Tests
+
+ @Test("하이브리드 아키텍처 - 네이버 경로 + Apple 도보 시간 검증", .tags(.success, .hybrid, .route))
+ func 하이브리드아키텍처_네이버경로_Apple시간() async throws {
+ // Given
+ let repository = MockDirectionRepository()
+
+ // When
+ let result = try await repository.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then - 하이브리드 아키텍처 검증
+ // 경로는 네이버에서 (복잡한 좌표 배열)
+ #expect(result.paths.count > 2, "네이버 API는 상세한 경로를 제공해야 함")
+
+ // 시간은 Apple에서 (합리적인 도보 시간)
+ let distance = Double(result.distance) // 미터
+ let duration = Double(result.duration) // 분
+ let speed = distance / (duration * 60) // m/s
+
+ // 일반적인 도보 속도: 1.0-2.0 m/s 범위를 넉넉히 허용
+ #expect(speed > 0.5, "도보 속도가 너무 느림 — Apple 시간이 과도하게 산정됨")
+ #expect(speed < 3.0, "도보 속도가 너무 빠름 — Apple 시간이 과소 산정됨")
+ }
+}
diff --git a/Projects/Data/Repository/Tests/Sources/TestTags.swift b/Projects/Data/Repository/Tests/Sources/TestTags.swift
new file mode 100644
index 0000000..539e05a
--- /dev/null
+++ b/Projects/Data/Repository/Tests/Sources/TestTags.swift
@@ -0,0 +1,21 @@
+//
+// TestTags.swift
+// RepositoryTests
+//
+// Created by Wonji Suh on 2026-03-12
+// Copyright © 2026 TimeSpot, Ltd., All rights reserved.
+//
+
+import Testing
+
+// MARK: - Swift Testing Tags
+extension Tag {
+ @Tag static var unit: Self
+ @Tag static var integration: Self
+ @Tag static var repository: Self
+ @Tag static var route: Self
+ @Tag static var success: Self
+ @Tag static var error: Self
+ @Tag static var performance: Self
+ @Tag static var hybrid: Self
+}
diff --git a/Projects/Data/Service/Project.swift b/Projects/Data/Service/Project.swift
index 308933f..85c87f2 100644
--- a/Projects/Data/Service/Project.swift
+++ b/Projects/Data/Service/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Service",
bundleId: .appBundleID(name: ".Service"),
product: .staticFramework,
diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift
new file mode 100644
index 0000000..2b400a5
--- /dev/null
+++ b/Projects/Data/Service/Sources/Auth/AuthService.swift
@@ -0,0 +1,76 @@
+//
+// AuthService.swift
+// Service
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import Entity
+
+import API
+import Foundations
+
+import AsyncMoya
+
+public enum AuthService {
+ case login(body: OAuthLoginRequest)
+ case refresh(refreshToken: String)
+ case logout
+
+}
+
+
+extension AuthService: BaseTargetType {
+ public typealias Domain = TimeSpotDomain
+
+ public var domain: TimeSpotDomain {
+ switch self {
+ case .login, .refresh, .logout:
+ return .auth
+ }
+ }
+
+ public var urlPath: String {
+ switch self {
+ case .login:
+ return AuthAPI.login.description
+ case .refresh:
+ return AuthAPI.refresh.description
+ case .logout:
+ return AuthAPI.logout.description
+ }
+ }
+
+ public var error: [Int : NetworkError]? {
+ return nil
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .login, .refresh, .logout:
+ return .post
+ }
+ }
+
+ public var parameters: [String : Any]? {
+ switch self {
+ case .login(let body):
+ return body.toDictionary
+ case .refresh(let refreshToken):
+ return refreshToken.toDictionary(key: "refreshToken")
+ case .logout:
+ return nil
+ }
+ }
+
+ public var headers: [String : String]? {
+ switch self {
+ case .logout:
+ return APIHeader.baseHeader
+ default:
+ return APIHeader.notAccessTokenHeader
+ }
+ }
+
+}
diff --git a/Projects/Data/Service/Sources/Auth/OAuthLoginRequest.swift b/Projects/Data/Service/Sources/Auth/OAuthLoginRequest.swift
new file mode 100644
index 0000000..1849189
--- /dev/null
+++ b/Projects/Data/Service/Sources/Auth/OAuthLoginRequest.swift
@@ -0,0 +1,22 @@
+//
+// OAuthLoginRequest.swift
+// Service
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+public struct OAuthLoginRequest: Encodable {
+ public let provider: String
+ public let idToken: String
+
+ public init(
+ provider: String,
+ idToken: String
+ ) {
+ self.provider = provider
+ self.idToken = idToken
+ }
+}
+
diff --git a/Projects/Data/Service/Sources/SignUp/SignUpRequestDTO.swift b/Projects/Data/Service/Sources/SignUp/SignUpRequestDTO.swift
new file mode 100644
index 0000000..c81da6d
--- /dev/null
+++ b/Projects/Data/Service/Sources/SignUp/SignUpRequestDTO.swift
@@ -0,0 +1,28 @@
+//
+// SignUpRequestDTO.swift
+// Service
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+public struct SignUpRequestDTO: Encodable {
+ public let provider: String
+ public let authCode: String
+ public let nickname: String
+ public let email: String
+ public let mapApi: String
+
+ public init(
+ provider: String,
+ authCode: String,
+ nickname: String,
+ email: String,
+ mapApi: String
+ ) {
+ self.provider = provider
+ self.authCode = authCode
+ self.nickname = nickname
+ self.email = email
+ self.mapApi = mapApi
+ }
+}
diff --git a/Projects/Data/Service/Sources/SignUp/SignUpService.swift b/Projects/Data/Service/Sources/SignUp/SignUpService.swift
new file mode 100644
index 0000000..440afd7
--- /dev/null
+++ b/Projects/Data/Service/Sources/SignUp/SignUpService.swift
@@ -0,0 +1,56 @@
+//
+// SignUpService.swift
+// Service
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+
+import Foundation
+
+import API
+import Foundations
+
+import AsyncMoya
+
+public enum SignUpService{
+ case signUp(body: SignUpRequestDTO)
+}
+
+
+extension SignUpService: BaseTargetType {
+ public typealias Domain = TimeSpotDomain
+
+ public var domain: TimeSpotDomain {
+ switch self {
+ case .signUp:
+ return .auth
+ }
+ }
+
+ public var urlPath: String {
+ switch self {
+ case .signUp:
+ return SignUpAPI.signUp.description
+
+ }
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .signUp:
+ return .post
+ }
+ }
+
+ public var error: [Int : AsyncMoya.NetworkError]? {
+ return nil
+ }
+
+ public var parameters: [String : Any]? {
+ switch self {
+ case .signUp(let body):
+ return body.toDictionary
+ }
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift
new file mode 100644
index 0000000..84c0f42
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift
@@ -0,0 +1,42 @@
+//
+// AuthInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 7/23/25.
+// Updated for WeaveDI v4.0 - Protocol-based DI Registration
+//
+
+import Foundation
+import WeaveDI
+import ComposableArchitecture
+import Entity
+
+/// Auth 관련 비즈니스 로직을 위한 Interface 프로토콜
+public protocol AuthInterface: Sendable {
+ func login(provider: SocialType, token: String) async throws -> LoginEntity
+ func refresh() async throws -> AuthTokens
+// func withDraw(token: String) async throws -> WithdrawEntity
+// func logout() async throws -> AuthExitEntity
+ func updateSessionCredential(with tokens: AuthTokens)
+}
+
+/// Auth Repository의 DependencyKey 구조체
+public struct AuthRepositoryDependency: DependencyKey {
+ public static var liveValue: AuthInterface {
+ UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl()
+ }
+
+ public static var testValue: AuthInterface {
+ UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl()
+ }
+
+ public static var previewValue: AuthInterface = liveValue
+}
+
+/// DependencyValues extension으로 간편한 접근 제공
+public extension DependencyValues {
+ var authRepository: AuthInterface {
+ get { self[AuthRepositoryDependency.self] }
+ set { self[AuthRepositoryDependency.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift
new file mode 100644
index 0000000..13b78cc
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift
@@ -0,0 +1,52 @@
+//
+// DefaultAuthRepositoryImpl.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 7/23/25.
+// Moved from Repository module
+//
+
+import Foundation
+import Model
+import Entity
+
+/// Auth Repository의 기본 구현체 (테스트/프리뷰용)
+final public class DefaultAuthRepositoryImpl: AuthInterface {
+ public init() {}
+
+ public func login(provider: Entity.SocialType, token: String) async throws -> Entity.LoginEntity {
+ return LoginEntity(
+ name: "Mock User",
+ isNewUser: false,
+ provider: provider,
+ token: AuthTokens(
+ accessToken: "mock_access_token_\(UUID().uuidString)",
+ refreshToken: "mock_refresh_token_\(UUID().uuidString)"
+ ), email: "test@test.com",
+ )
+ }
+
+ public func refresh() async throws -> Entity.AuthTokens {
+ return AuthTokens(
+ accessToken: "mock_refreshed_access_token_\(UUID().uuidString)",
+ refreshToken: "mock_refreshed_refresh_token_\(UUID().uuidString)"
+ )
+ }
+
+// public func withDraw(token: String) async throws -> WithdrawEntity {
+// return WithdrawEntity(isSuccess: true)
+// }
+//
+// public func logout() async throws -> AuthExitEntity {
+// // Mock 로그아웃 성공 응답
+// return AuthExitEntity(
+// code: "200",
+// message: "로그아웃이 성공적으로 완료되었습니다.",
+// detail: "사용자 세션이 종료되었습니다."
+// )
+// }
+
+ public func updateSessionCredential(with tokens: AuthTokens) {
+ // Mock 구현체에서는 아무것도 하지 않음 (테스트/프리뷰용)
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/Direction/DirectionRepositoryProtocol.swift b/Projects/Domain/DomainInterface/Sources/Direction/DirectionInterface.swift
similarity index 97%
rename from Projects/Domain/DomainInterface/Sources/Direction/DirectionRepositoryProtocol.swift
rename to Projects/Domain/DomainInterface/Sources/Direction/DirectionInterface.swift
index 0673a4e..1cf5ba4 100644
--- a/Projects/Domain/DomainInterface/Sources/Direction/DirectionRepositoryProtocol.swift
+++ b/Projects/Domain/DomainInterface/Sources/Direction/DirectionInterface.swift
@@ -1,5 +1,5 @@
//
-// DirectionRepositoryInterface.swift
+// DirectionInterface.swift
// DomainInterface
//
// Created by wonji suh on 2026-03-12
diff --git a/Projects/Domain/DomainInterface/Sources/Direction/GetRouteUseCaseInterface.swift b/Projects/Domain/DomainInterface/Sources/Direction/GetRouteUseCaseInterface.swift
deleted file mode 100644
index 19548a2..0000000
--- a/Projects/Domain/DomainInterface/Sources/Direction/GetRouteUseCaseInterface.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-//
-// GetRouteUseCaseInterface.swift
-// DomainInterface
-//
-// Created by wonji suh on 2026-03-12
-// Copyright © 2026 TimeSpot, Ltd., All rights reserved.
-//
-
-import Foundation
-import CoreLocation
-
-import Entity
-
-/// 경로 검색 UseCase 인터페이스
-public protocol GetRouteUseCaseInterface: Sendable {
- /// 경로를 검색합니다
- /// - Parameters:
- /// - start: 출발지 좌표
- /// - destination: 목적지 좌표
- /// - option: 경로 옵션 (기본값: 도보)
- /// - Returns: 경로 정보
- func execute(
- from start: CLLocationCoordinate2D,
- to destination: CLLocationCoordinate2D,
- option: RouteOption
- ) async throws -> RouteInfo
-}
\ No newline at end of file
diff --git a/Projects/Domain/DomainInterface/Sources/Manger/InMemoryKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manger/InMemoryKeychainManager.swift
index 17c20cc..d51a49b 100644
--- a/Projects/Domain/DomainInterface/Sources/Manger/InMemoryKeychainManager.swift
+++ b/Projects/Domain/DomainInterface/Sources/Manger/InMemoryKeychainManager.swift
@@ -13,50 +13,6 @@ public actor InMemoryKeychainManager: KeychainManagingInterface {
public init() {}
- // MARK: - Legacy Sync API (Backward Compatibility)
-
- public nonisolated func save(accessToken: String, refreshToken: String) {
- Task { [weak self] in
- guard let self = self else { return }
- try? await self.save(accessToken: accessToken, refreshToken: refreshToken)
- }
- }
-
- public nonisolated func saveAccessToken(_ token: String) {
- Task { [weak self] in
- guard let self = self else { return }
- try? await self.saveAccessToken(token)
- }
- }
-
- public nonisolated func saveRefreshToken(_ token: String) {
- Task { [weak self] in
- guard let self = self else { return }
- try? await self.saveRefreshToken(token)
- }
- }
-
- public nonisolated func accessToken() -> String? {
- // ⚠️ Sync access - use async version for better safety
- // For testing purposes, return nil in sync context
- return nil
- }
-
- public nonisolated func refreshToken() -> String? {
- // ⚠️ Sync access - use async version for better safety
- // For testing purposes, return nil in sync context
- return nil
- }
-
- public nonisolated func clear() {
- Task { [weak self] in
- guard let self = self else { return }
- try? await self.clear()
- }
- }
-
- // MARK: - Modern Async API (iOS 17+)
-
public func save(accessToken: String, refreshToken: String) async throws {
accessTokenStorage = accessToken
refreshTokenStorage = refreshToken
diff --git a/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagerInterface.swift
new file mode 100644
index 0000000..9659f28
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagerInterface.swift
@@ -0,0 +1,13 @@
+//
+// KeychainManagerInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 1/2/26.
+//
+
+import Foundation
+import WeaveDI
+
+/// KeychainManaging은 KeychainManagingInterface의 별칭입니다.
+public typealias KeychainManaging = KeychainManagingInterface
+
diff --git a/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagingInterface.swift b/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagingInterface.swift
index 3a2e397..b3270d0 100644
--- a/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagingInterface.swift
+++ b/Projects/Domain/DomainInterface/Sources/Manger/KeychainManagingInterface.swift
@@ -8,14 +8,6 @@
import Foundation
public protocol KeychainManagingInterface: Sendable {
- func save(accessToken: String, refreshToken: String)
- func saveAccessToken(_ token: String)
- func saveRefreshToken(_ token: String)
- func accessToken() -> String?
- func refreshToken() -> String?
- func clear()
-
- // MARK: - Modern Async API (iOS 17+)
func save(accessToken: String, refreshToken: String) async throws
func saveAccessToken(_ token: String) async throws
func saveRefreshToken(_ token: String) async throws
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleAuthRequestInterface.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleAuthRequestInterface.swift
new file mode 100644
index 0000000..9c98d6f
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleAuthRequestInterface.swift
@@ -0,0 +1,36 @@
+//
+// AppleAuthRequestInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 12/26/25.
+//
+
+import Foundation
+import AuthenticationServices
+import WeaveDI
+
+public protocol AppleAuthRequestInterface: Sendable {
+ func prepare(_ request: ASAuthorizationAppleIDRequest) -> String
+}
+
+///// OAuth Repository의 DependencyKey 구조체
+public struct AppleAuthRequestDependency: DependencyKey {
+ public static var liveValue: AppleAuthRequestInterface {
+ UnifiedDI.resolve(AppleAuthRequestInterface.self) ?? DefaultAppleAuthRequestImpl()
+ }
+
+ public static var testValue: AppleAuthRequestInterface {
+ UnifiedDI.resolve(AppleAuthRequestInterface.self) ?? DefaultAppleAuthRequestImpl()
+ }
+
+ public static var previewValue: AppleAuthRequestInterface = liveValue
+}
+
+/// DependencyValues extension으로 간편한 접근 제공
+public extension DependencyValues {
+ var appleManger: AppleAuthRequestInterface {
+ get { self[AppleAuthRequestDependency.self] }
+ set { self[AppleAuthRequestDependency.self] = newValue }
+ }
+}
+
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleOAuthInterface.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleOAuthInterface.swift
new file mode 100644
index 0000000..8e2a7b3
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleOAuthInterface.swift
@@ -0,0 +1,36 @@
+//
+// AppleOAuthInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+import AuthenticationServices
+
+import Entity
+
+import WeaveDI
+
+public protocol AppleOAuthInterface: Sendable {
+ func signIn() async throws -> AppleOAuthPayload
+ func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload
+}
+
+// MARK: - Dependencies
+public struct AppleOAuthRepositoryDependencyKey: DependencyKey {
+ public static var liveValue: AppleOAuthInterface {
+ UnifiedDI.resolve(AppleOAuthInterface.self) ?? MockAppleOAuthRepository()
+ }
+ public static var previewValue: AppleOAuthInterface {
+ UnifiedDI.resolve(AppleOAuthInterface.self) ?? MockAppleOAuthRepository()
+ }
+ public static var testValue: AppleOAuthInterface = MockAppleOAuthRepository()
+}
+
+public extension DependencyValues {
+ var appleOAuthRepository: AppleOAuthInterface {
+ get { self[AppleOAuthRepositoryDependencyKey.self] }
+ set { self[AppleOAuthRepositoryDependencyKey.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleOAuthProviderInterface.swift
new file mode 100644
index 0000000..814348c
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/AppleOAuthProviderInterface.swift
@@ -0,0 +1,68 @@
+//
+// AppleOAuthProviderInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+import AuthenticationServices
+import WeaveDI
+import Entity
+
+/// Apple OAuth Provider Interface 프로토콜
+public protocol AppleOAuthProviderInterface: Sendable {
+ func signInWithCredential(
+ credential: ASAuthorizationAppleIDCredential,
+ nonce: String
+ ) async throws -> AppleOAuthPayload
+
+ func signIn() async throws -> AppleOAuthPayload
+}
+
+/// Apple OAuth Provider의 DependencyKey 구조체
+public struct AppleOAuthProviderDependency: DependencyKey {
+ public static var liveValue: AppleOAuthProviderInterface {
+ UnifiedDI.resolve(AppleOAuthProviderInterface.self) ?? MockAppleOAuthProvider()
+ }
+
+ public static var testValue: AppleOAuthProviderInterface {
+ UnifiedDI.resolve(AppleOAuthProviderInterface.self) ?? MockAppleOAuthProvider()
+ }
+
+ public static var previewValue: AppleOAuthProviderInterface = testValue
+}
+
+/// DependencyValues extension으로 간편한 접근 제공
+public extension DependencyValues {
+ var appleOAuthProvider: AppleOAuthProviderInterface {
+ get { self[AppleOAuthProviderDependency.self] }
+ set { self[AppleOAuthProviderDependency.self] = newValue }
+ }
+}
+
+/// 테스트용 Mock 구현체
+public struct MockAppleOAuthProvider: AppleOAuthProviderInterface {
+ public init() {}
+
+ public func signInWithCredential(
+ credential: ASAuthorizationAppleIDCredential,
+ nonce: String
+ ) async throws -> AppleOAuthPayload {
+ return AppleOAuthPayload(
+ idToken: "mock_id_token",
+ authorizationCode: "mock_auth_code",
+ displayName: "Mock User",
+ nonce: nonce
+ )
+ }
+
+ public func signIn() async throws -> AppleOAuthPayload {
+ return AppleOAuthPayload(
+ idToken: "mock_id_token",
+ authorizationCode: "mock_auth_code",
+ displayName: "Mock User",
+ nonce: "mock_nonce"
+ )
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Apple/DefaultAppleAuthRequestImpl.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/DefaultAppleAuthRequestImpl.swift
new file mode 100644
index 0000000..911dc97
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/DefaultAppleAuthRequestImpl.swift
@@ -0,0 +1,68 @@
+//
+// DefaultAppleAuthRequestImpl.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 12/26/25.
+//
+
+import Foundation
+
+import AuthenticationServices
+import CryptoKit
+
+
+/// Default fallback implementation of AppleAuthRequestInterface
+public struct DefaultAppleAuthRequestImpl: AppleAuthRequestInterface {
+
+ public init() {}
+
+ public func prepare(_ request: ASAuthorizationAppleIDRequest) -> String {
+ let nonce = randomNonceString()
+ request.requestedScopes = [.email, .fullName]
+ request.nonce = sha256(nonce)
+ return nonce
+ }
+
+ private func sha256(_ input: String) -> String {
+ let inputData = Data(input.utf8)
+ let hashedData = SHA256.hash(data: inputData)
+ let hashString = hashedData.compactMap {
+ String(format: "%02x", $0)
+ }.joined()
+ return hashString
+ }
+
+ private func randomNonceString(length: Int = 32) -> String {
+ precondition(length > 0)
+ let charset: [Character] =
+ Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
+ var result = ""
+ var remainingLength = length
+
+ while remainingLength > 0 {
+ let randoms: [UInt8] = (0 ..< 16).map { _ in
+ var random: UInt8 = 0
+ let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
+ if errorCode != errSecSuccess {
+ fatalError(
+ "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)"
+ )
+ }
+ return random
+ }
+
+ randoms.forEach { random in
+ if remainingLength == 0 {
+ return
+ }
+
+ if random < charset.count {
+ result.append(charset[Int(random)])
+ remainingLength -= 1
+ }
+ }
+ }
+
+ return result
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Apple/MockAppleOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/MockAppleOAuthRepository.swift
new file mode 100644
index 0000000..3e92d83
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Apple/MockAppleOAuthRepository.swift
@@ -0,0 +1,228 @@
+//
+// MockAppleOAuthRepositoryImpl.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+import AuthenticationServices
+import Entity
+
+public actor MockAppleOAuthRepository: AppleOAuthInterface {
+ public init() {}
+ // MARK: - Configuration
+ public enum Configuration {
+ case success
+ case failure
+ case userCancelled
+ case invalidCredentials
+ case customUser(String)
+ case networkError
+ case customDelay(TimeInterval)
+
+ var shouldSucceed: Bool {
+ switch self {
+ case .success, .customUser, .customDelay:
+ return true
+ case .failure, .userCancelled, .invalidCredentials, .networkError:
+ return false
+ }
+ }
+
+ var delay: TimeInterval {
+ switch self {
+ case .customDelay(let delay):
+ return delay
+ default:
+ return 0.1
+ }
+ }
+
+ var mockUserName: String {
+ switch self {
+ case .customUser(let name):
+ return name
+ default:
+ return "Mock Apple User"
+ }
+ }
+
+ var error: MockAppleOAuthError? {
+ switch self {
+ case .success, .customUser, .customDelay:
+ return nil
+ case .failure:
+ return .signInFailed
+ case .userCancelled:
+ return .userCancelled
+ case .invalidCredentials:
+ return .invalidCredentials
+ case .networkError:
+ return .networkError
+ }
+ }
+ }
+
+ // MARK: - State
+
+ private var configuration: Configuration = .success
+ private var signInCallCount = 0
+ private var lastSignInCall: Date?
+
+ // MARK: - Public Configuration Methods
+
+ public init(configuration: Configuration = .success) {
+ self.configuration = configuration
+ }
+
+ public func setConfiguration(_ configuration: Configuration) {
+ self.configuration = configuration
+ signInCallCount = 0
+ lastSignInCall = nil
+ }
+
+ public func getSignInCallCount() -> Int {
+ return signInCallCount
+ }
+
+ public func getLastSignInCall() -> Date? {
+ return lastSignInCall
+ }
+
+ public func reset() {
+ configuration = .success
+ signInCallCount = 0
+ lastSignInCall = nil
+ }
+
+ // MARK: - AppleOAuthRepositoryProtocol Implementation
+
+ public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload {
+ // Track call
+ signInCallCount += 1
+ lastSignInCall = Date()
+
+ // Apply delay
+ if configuration.delay > 0 {
+ try await Task.sleep(for: .seconds(configuration.delay))
+ }
+
+ // Handle failure scenarios
+ if !configuration.shouldSucceed, let error = configuration.error {
+ throw error
+ }
+
+ // Return success payload using provided nonce
+ return AppleOAuthPayload(
+ idToken: createMockIDToken(),
+ authorizationCode: createMockAuthCode(),
+ displayName: configuration.mockUserName.isEmpty ? nil : configuration.mockUserName,
+ nonce: nonce
+ )
+ }
+
+ public func signIn() async throws -> AppleOAuthPayload {
+ // Track call
+ signInCallCount += 1
+ lastSignInCall = Date()
+
+ // Apply delay
+ if configuration.delay > 0 {
+ try await Task.sleep(for: .seconds(configuration.delay))
+ }
+
+ // Handle failure scenarios
+ if !configuration.shouldSucceed, let error = configuration.error {
+ throw error
+ }
+
+ // Return success payload
+ return AppleOAuthPayload(
+ idToken: createMockIDToken(),
+ authorizationCode: createMockAuthCode(),
+ displayName: configuration.mockUserName.isEmpty ? nil : configuration.mockUserName,
+ nonce: createMockNonce()
+ )
+ }
+
+ // MARK: - Private Helper Methods
+
+ private func createMockIDToken() -> String {
+ let header = "eyJhbGciOiJSUzI1NiIsImtpZCI6Im1vY2sta2lkIn0"
+ let payload = "eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vY2suYXBwIiwic3ViIjoibW9jay5hcHBsZS51c2VyIn0"
+ let signature = "mock-apple-signature-\(UUID().uuidString.prefix(10))"
+ return "\(header).\(payload).\(signature)"
+ }
+
+ private func createMockNonce() -> String {
+ return "mock-apple-nonce-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))"
+ }
+
+ private func createMockAuthCode() -> String {
+ return "c_\(UUID().uuidString.prefix(20)).0.\(configuration.mockUserName.prefix(5))"
+ }
+}
+
+// MARK: - Convenience Static Methods
+
+public extension MockAppleOAuthRepository {
+
+ /// Creates a pre-configured actor for success scenario
+ static func success() -> MockAppleOAuthRepository {
+ return MockAppleOAuthRepository(configuration: .success)
+ }
+
+ /// Creates a pre-configured actor for failure scenario
+ static func failure() -> MockAppleOAuthRepository {
+ return MockAppleOAuthRepository(configuration: .failure)
+ }
+
+ /// Creates a pre-configured actor for user cancelled scenario
+ static func userCancelled() -> MockAppleOAuthRepository {
+ return MockAppleOAuthRepository(configuration: .userCancelled)
+ }
+
+ /// Creates a pre-configured actor for custom user scenario
+ static func customUser(_ name: String) -> MockAppleOAuthRepository {
+ return MockAppleOAuthRepository(configuration: .customUser(name))
+ }
+
+ /// Creates a pre-configured actor for network error scenario
+ static func networkError() -> MockAppleOAuthRepository {
+ return MockAppleOAuthRepository(configuration: .networkError)
+ }
+
+ /// Creates a pre-configured actor with custom delay
+ static func withDelay(_ delay: TimeInterval) -> MockAppleOAuthRepository {
+ return MockAppleOAuthRepository(configuration: .customDelay(delay))
+ }
+}
+
+// MARK: - Mock Errors
+
+public enum MockAppleOAuthError: Error, LocalizedError {
+ case signInFailed
+ case userCancelled
+ case invalidCredentials
+ case networkError
+ case missingIdentityToken
+ case unknownError
+
+ public var errorDescription: String? {
+ switch self {
+ case .signInFailed:
+ return "Mock Apple OAuth sign in failed"
+ case .userCancelled:
+ return "Mock Apple OAuth user cancelled"
+ case .invalidCredentials:
+ return "Mock Apple OAuth invalid credentials"
+ case .networkError:
+ return "Mock Apple OAuth network error"
+ case .missingIdentityToken:
+ return "Mock Apple OAuth missing identity token"
+ case .unknownError:
+ return "Mock Apple OAuth unknown error"
+ }
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Google/GoogleOAuthInterface.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Google/GoogleOAuthInterface.swift
new file mode 100644
index 0000000..e607cb9
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Google/GoogleOAuthInterface.swift
@@ -0,0 +1,49 @@
+//
+// GoogleOAuthInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import WeaveDI
+import Entity
+
+/// Google OAuth Provider Interface 프로토콜
+public protocol GoogleOAuthProviderInterface: Sendable {
+ func signInWithToken(
+ token: String
+ ) async throws -> GoogleOAuthPayload
+}
+
+/// Google OAuth Provider의 DependencyKey 구조체
+public struct GoogleOAuthProviderDependency: DependencyKey {
+ public static var liveValue: GoogleOAuthProviderInterface {
+ UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider()
+ }
+
+ public static var testValue: GoogleOAuthProviderInterface {
+ UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider()
+ }
+
+ public static var previewValue: GoogleOAuthProviderInterface = testValue
+}
+
+/// DependencyValues extension으로 간편한 접근 제공
+public extension DependencyValues {
+ var googleOAuthProvider: GoogleOAuthProviderInterface {
+ get { self[GoogleOAuthProviderDependency.self] }
+ set { self[GoogleOAuthProviderDependency.self] = newValue }
+ }
+}
+
+/// 테스트용 Mock 구현체
+public struct MockGoogleOAuthProvider: GoogleOAuthProviderInterface {
+ public init() {}
+
+ public func signInWithToken(
+ token: String
+ ) async throws -> GoogleOAuthPayload {
+ return GoogleOAuthPayload(idToken: "", accessToken: "", authorizationCode: "", displayName: "")
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Google/GoogleOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Google/GoogleOAuthProviderInterface.swift
new file mode 100644
index 0000000..86e930b
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Google/GoogleOAuthProviderInterface.swift
@@ -0,0 +1,32 @@
+//
+// GoogleOAuthProviderInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import Entity
+import WeaveDI
+
+public protocol GoogleOAuthInterface: Sendable {
+ func signIn() async throws -> GoogleOAuthPayload
+}
+
+public struct GoogleOAuthRepositoryDependencyKey: DependencyKey {
+ public static var liveValue: GoogleOAuthInterface {
+ UnifiedDI.resolve(GoogleOAuthInterface.self) ?? MockGoogleOAuthRepository()
+ }
+ public static var previewValue: GoogleOAuthInterface {
+ UnifiedDI.resolve(GoogleOAuthInterface.self) ?? MockGoogleOAuthRepository()
+ }
+ public static var testValue: GoogleOAuthInterface = MockGoogleOAuthRepository()
+}
+
+public extension DependencyValues {
+ var googleOAuthRepository: GoogleOAuthInterface {
+ get { self[GoogleOAuthRepositoryDependencyKey.self] }
+ set { self[GoogleOAuthRepositoryDependencyKey.self] = newValue }
+ }
+}
+
diff --git a/Projects/Domain/DomainInterface/Sources/OAuth/Google/MockGoogleOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/OAuth/Google/MockGoogleOAuthRepository.swift
new file mode 100644
index 0000000..07e168e
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/OAuth/Google/MockGoogleOAuthRepository.swift
@@ -0,0 +1,184 @@
+//
+// MockGoogleOAuthRepository.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import Entity
+
+public actor MockGoogleOAuthRepository: GoogleOAuthInterface {
+
+ public init() {}
+
+ // MARK: - Configuration
+ public enum Configuration {
+ case success
+ case failure
+ case customUser(String)
+ case networkError
+ case customDelay(TimeInterval)
+
+ var shouldSucceed: Bool {
+ switch self {
+ case .success, .customUser, .customDelay:
+ return true
+ case .failure, .networkError:
+ return false
+ }
+ }
+
+ var delay: TimeInterval {
+ switch self {
+ case .customDelay(let delay):
+ return delay
+ default:
+ return 0.1
+ }
+ }
+
+ var mockUserName: String {
+ switch self {
+ case .customUser(let name):
+ return name
+ default:
+ return "Mock Google User"
+ }
+ }
+ }
+
+ // MARK: - State
+
+ private var configuration: Configuration = .success
+ private var signInCallCount = 0
+ private var lastSignInCall: Date?
+
+ // MARK: - Public Configuration Methods
+
+ public init(configuration: Configuration = .success) {
+ self.configuration = configuration
+ }
+
+ public func setConfiguration(_ configuration: Configuration) {
+ self.configuration = configuration
+ signInCallCount = 0
+ lastSignInCall = nil
+ }
+
+ public func getSignInCallCount() -> Int {
+ return signInCallCount
+ }
+
+ public func getLastSignInCall() -> Date? {
+ return lastSignInCall
+ }
+
+ public func reset() {
+ configuration = .success
+ signInCallCount = 0
+ lastSignInCall = nil
+ }
+
+ // MARK: - GoogleOAuthRepositoryProtocol Implementation
+
+ public func signIn() async throws -> GoogleOAuthPayload {
+ // Track call
+ signInCallCount += 1
+ lastSignInCall = Date()
+
+ // Apply delay
+ if configuration.delay > 0 {
+ try await Task.sleep(for: .seconds(configuration.delay))
+ }
+
+ // Handle failure scenarios
+ if !configuration.shouldSucceed {
+ switch configuration {
+ case .failure:
+ throw MockGoogleOAuthError.signInFailed
+ case .networkError:
+ throw MockGoogleOAuthError.networkError
+ default:
+ throw MockGoogleOAuthError.unknownError
+ }
+ }
+
+ // Return success payload
+ return GoogleOAuthPayload(
+ idToken: createMockIDToken(),
+ accessToken: createMockAccessToken(),
+ authorizationCode: createMockAuthCode(),
+ displayName: configuration.mockUserName
+ )
+ }
+
+ // MARK: - Private Helper Methods
+
+ private func createMockIDToken() -> String {
+ return "mock.google.idtoken.\(UUID().uuidString.prefix(8))"
+ }
+
+ private func createMockAccessToken() -> String {
+ return "ya29.mock-google-access-token-\(UUID().uuidString.prefix(12))"
+ }
+
+ private func createMockAuthCode() -> String {
+ return "4/mock-google-auth-code-\(UUID().uuidString.prefix(10))"
+ }
+}
+
+// MARK: - Convenience Static Methods
+
+public extension MockGoogleOAuthRepository {
+
+ /// Creates a pre-configured actor for success scenario
+ static func success() -> MockGoogleOAuthRepository {
+ return MockGoogleOAuthRepository(configuration: .success)
+ }
+
+ /// Creates a pre-configured actor for failure scenario
+ static func failure() -> MockGoogleOAuthRepository {
+ return MockGoogleOAuthRepository(configuration: .failure)
+ }
+
+ /// Creates a pre-configured actor for custom user scenario
+ static func customUser(_ name: String) -> MockGoogleOAuthRepository {
+ return MockGoogleOAuthRepository(configuration: .customUser(name))
+ }
+
+ /// Creates a pre-configured actor for network error scenario
+ static func networkError() -> MockGoogleOAuthRepository {
+ return MockGoogleOAuthRepository(configuration: .networkError)
+ }
+
+ /// Creates a pre-configured actor with custom delay
+ static func withDelay(_ delay: TimeInterval) -> MockGoogleOAuthRepository {
+ return MockGoogleOAuthRepository(configuration: .customDelay(delay))
+ }
+}
+
+// MARK: - Mock Errors
+
+public enum MockGoogleOAuthError: Error, LocalizedError {
+ case signInFailed
+ case networkError
+ case invalidCredentials
+ case userCancelled
+ case unknownError
+
+ public var errorDescription: String? {
+ switch self {
+ case .signInFailed:
+ return "Mock Google OAuth sign in failed"
+ case .networkError:
+ return "Mock Google OAuth network error"
+ case .invalidCredentials:
+ return "Mock Google OAuth invalid credentials"
+ case .userCancelled:
+ return "Mock Google OAuth user cancelled"
+ case .unknownError:
+ return "Mock Google OAuth unknown error"
+ }
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift
new file mode 100644
index 0000000..aa31f7e
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift
@@ -0,0 +1,174 @@
+//
+// DefaultSignUpRepositoryImpl.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 7/23/25.
+// Moved from Repository module
+//
+
+import Foundation
+import Entity
+
+/// SignUp Repository의 기본 구현체 (테스트/프리뷰용)
+final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Sendable {
+
+ // MARK: - Configuration
+ public enum Configuration {
+ case success
+ case failure
+ case invalidInviteCode
+ case expiredInviteCode
+ case networkError
+ case serverError
+ case customDelay(TimeInterval)
+
+ var shouldSucceed: Bool {
+ switch self {
+ case .success, .customDelay:
+ return true
+ case .failure, .invalidInviteCode, .expiredInviteCode, .networkError, .serverError:
+ return false
+ }
+ }
+
+ var delay: TimeInterval {
+ switch self {
+ case .customDelay(let delay):
+ return delay
+ default:
+ return 1.0 // 실제 네트워크 지연 시뮬레이션
+ }
+ }
+
+ var signUpError: SignUpError? {
+ switch self {
+ case .success, .customDelay:
+ return nil
+ case .failure:
+ return .accountCreationFailed
+ case .invalidInviteCode:
+ return .invalidInviteCode
+ case .expiredInviteCode:
+ return .expiredInviteCode
+ case .networkError:
+ return .networkError
+ case .serverError:
+ return .serverError("서버 내부 오류")
+ }
+ }
+ }
+
+ // MARK: - Properties (Test implementation - @unchecked Sendable)
+ private var configuration: Configuration = .success
+ private var registerCallCount = 0
+ private var validateCallCount = 0
+ private var checkEmailCallCount = 0
+ private var lastCall: Date?
+
+ // MARK: - Initialization
+
+ public init(configuration: Configuration = .success) {
+ self.configuration = configuration
+ }
+
+ // MARK: - Configuration Methods
+
+ public func setConfiguration(_ configuration: Configuration) {
+ self.configuration = configuration
+ registerCallCount = 0
+ validateCallCount = 0
+ checkEmailCallCount = 0
+ lastCall = nil
+ }
+
+ public func getRegisterCallCount() -> Int { registerCallCount }
+ public func getValidateCallCount() -> Int { validateCallCount }
+ public func getCheckEmailCallCount() -> Int { checkEmailCallCount }
+ public func getLastCall() -> Date? { lastCall }
+
+ public func reset() {
+ configuration = .success
+ registerCallCount = 0
+ validateCallCount = 0
+ checkEmailCallCount = 0
+ lastCall = nil
+ }
+
+ // MARK: - SignUpInterface Implementation
+
+ public func registerUser(
+ input: SignUpInput
+ ) async throws -> LoginEntity {
+ // Track call
+ registerCallCount += 1
+ lastCall = Date()
+
+ // Apply delay
+ if configuration.delay > 0 {
+ try await Task.sleep(for: .seconds(configuration.delay))
+ }
+
+ // 입력 값 검증
+ guard !input.name.isEmpty else {
+ throw SignUpError.missingRequiredField("이름")
+ }
+
+ guard !input.authCode.isEmpty else {
+ throw SignUpError.missingRequiredField("인증 코드")
+ }
+
+ // Configuration 기반 응답 처리
+ if !configuration.shouldSucceed, let error = configuration.signUpError {
+ throw error
+ }
+
+ // Success case - 더미 토큰 생성 (테스트용)
+ let authTokens = AuthTokens(
+ accessToken: "mock_access_token_\(UUID().uuidString)",
+ refreshToken: "mock_refresh_token_\(UUID().uuidString)"
+ )
+
+ return LoginEntity(
+ name: input.name,
+ isNewUser: true, // 회원가입이므로 신규 사용자
+ provider: input.provider,
+ token: authTokens,
+ email: input.email
+ )
+ }
+}
+
+// MARK: - Convenience Static Methods
+
+public extension DefaultSignUpRepositoryImpl {
+
+ /// Creates a pre-configured instance for success scenario
+ static func success() -> DefaultSignUpRepositoryImpl {
+ return DefaultSignUpRepositoryImpl(configuration: .success)
+ }
+
+ /// Creates a pre-configured instance for failure scenario
+ static func failure() -> DefaultSignUpRepositoryImpl {
+ return DefaultSignUpRepositoryImpl(configuration: .failure)
+ }
+
+ /// Creates a pre-configured instance for invalid invite code scenario
+ static func invalidInviteCode() -> DefaultSignUpRepositoryImpl {
+ return DefaultSignUpRepositoryImpl(configuration: .invalidInviteCode)
+ }
+
+ /// Creates a pre-configured instance for expired invite code scenario
+ static func expiredInviteCode() -> DefaultSignUpRepositoryImpl {
+ return DefaultSignUpRepositoryImpl(configuration: .expiredInviteCode)
+ }
+
+ /// Creates a pre-configured instance for network error scenario
+ static func networkError() -> DefaultSignUpRepositoryImpl {
+ return DefaultSignUpRepositoryImpl(configuration: .networkError)
+ }
+
+ /// Creates a pre-configured instance with custom delay
+ static func withDelay(_ delay: TimeInterval) -> DefaultSignUpRepositoryImpl {
+ return DefaultSignUpRepositoryImpl(configuration: .customDelay(delay))
+ }
+}
diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift b/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift
new file mode 100644
index 0000000..f182fa4
--- /dev/null
+++ b/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift
@@ -0,0 +1,39 @@
+//
+// SignUpInterface.swift
+// DomainInterface
+//
+// Created by Wonji Suh on 7/23/25.
+//
+
+import Foundation
+import Entity
+import WeaveDI
+
+public protocol SignUpInterface: Sendable {
+ func registerUser(
+ input: SignUpInput
+ ) async throws -> LoginEntity
+}
+
+/// SignUp Repository의 DependencyKey 구조체
+public struct SignUpRepositoryDependency: DependencyKey {
+ public static var liveValue: SignUpInterface {
+ UnifiedDI.resolve(SignUpInterface.self) ?? DefaultSignUpRepositoryImpl()
+ }
+
+ public static var testValue: SignUpInterface {
+ DefaultSignUpRepositoryImpl.success()
+ }
+
+ public static var previewValue: SignUpInterface {
+ DefaultSignUpRepositoryImpl.success()
+ }
+}
+
+/// DependencyValues extension으로 간편한 접근 제공
+public extension DependencyValues {
+ var signUpRepository: SignUpInterface {
+ get { self[SignUpRepositoryDependency.self] }
+ set { self[SignUpRepositoryDependency.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/Error/AuthError.swift b/Projects/Domain/Entity/Sources/Error/AuthError.swift
new file mode 100644
index 0000000..1c94b92
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/Error/AuthError.swift
@@ -0,0 +1,117 @@
+//
+// AuthError.swift
+// Entity
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+
+public enum AuthError: Error, Equatable, LocalizedError, Hashable {
+ /// 설정 누락 (Google/Supabase 키 등)
+ case configurationMissing
+ /// 프레젠트할 컨트롤러가 없음
+ case missingPresentingController
+ /// ID 토큰 없음
+ case missingIDToken
+ /// 사용자가 로그인 플로우를 취소한 경우
+ case userCancelled
+ /// 자격 증명 문제 (예: 잘못된 nonce, credential 등)
+ case invalidCredential(String)
+ /// 네트워크/통신 문제
+ case networkError(String)
+ /// Supabase나 백엔드 쪽에서 온 에러
+ case backendError(String)
+ /// 약관 동의가 필요한 경우
+ case needsTermsAgreement(String)
+ /// 회원 탈퇴 실패
+ case accountDeletionFailed
+ /// 회원 탈퇴 권한 없음
+ case accountDeletionNotAllowed
+ /// 이미 탈퇴된 계정
+ case accountAlreadyDeleted
+ /// refresh token이 만료된 경우
+ case refreshTokenExpired
+ /// 그 외 알 수 없는 에러
+ case unknownError(String)
+
+ // MARK: - LocalizedError
+
+ public var errorDescription: String? {
+ switch self {
+ case .configurationMissing:
+ return "인증 설정이 올바르게 구성되지 않았습니다."
+ case .missingPresentingController:
+ return "프레젠트할 뷰 컨트롤러를 찾을 수 없습니다."
+ case .missingIDToken:
+ return "ID 토큰을 가져오지 못했습니다."
+ case .userCancelled:
+ return "사용자가 로그인을 취소했습니다."
+ case .invalidCredential(let message):
+ return "잘못된 자격 증명입니다: \(message)"
+ case .networkError(let message):
+ return "네트워크 오류가 발생했습니다: \(message)"
+ case .backendError(let message):
+ return "서버에서 오류가 발생했습니다: \(message)"
+ case .needsTermsAgreement(let message):
+ return "\(message)"
+ case .accountDeletionFailed:
+ return "회원 탈퇴에 실패했습니다."
+ case .accountDeletionNotAllowed:
+ return "회원 탈퇴 권한이 없습니다."
+ case .accountAlreadyDeleted:
+ return "이미 탈퇴된 계정입니다."
+ case .refreshTokenExpired:
+ return "로그인이 만료되었습니다. 다시 로그인해주세요."
+ case .unknownError(let message):
+ return "알 수 없는 오류가 발생했습니다: \(message)"
+ }
+ }
+}
+
+// MARK: - Convenience Methods
+
+public extension AuthError {
+ static func from(_ error: Error) -> AuthError {
+ if let authError = error as? AuthError {
+ return authError
+ }
+ return .unknownError(error.localizedDescription)
+ }
+
+ var isNetworkError: Bool {
+ switch self {
+ case .networkError:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var isRetryable: Bool {
+ switch self {
+ case .networkError, .backendError:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var isAccountDeletionError: Bool {
+ switch self {
+ case .accountDeletionFailed, .accountDeletionNotAllowed, .accountAlreadyDeleted:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var isTokenExpiredError: Bool {
+ switch self {
+ case .refreshTokenExpired:
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/Error/SignUpError.swift b/Projects/Domain/Entity/Sources/Error/SignUpError.swift
new file mode 100644
index 0000000..2c9e9fd
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/Error/SignUpError.swift
@@ -0,0 +1,165 @@
+//
+// SignUpError.swift
+// Entity
+//
+// Created by Wonji Suh on 12/30/25.
+//
+
+import Foundation
+
+public enum SignUpError: Error, LocalizedError, Equatable {
+ // MARK: - Invite Code Related Errors
+ case invalidInviteCode
+ case expiredInviteCode
+
+ // MARK: - Job Related Errors
+ case invalidJob
+ case jobNotSelected
+ case jobNotAvailable
+
+ // MARK: - Account Related Errors
+ case accountAlreadyExists
+ case accountCreationFailed
+
+ // MARK: - Validation Errors
+ case nameTooShort
+ case nameTooLong
+
+ // MARK: - Network & Server Errors
+ case networkError
+ case serverError(String)
+
+ // MARK: - General Errors
+ case unknownError(String)
+ case userCancelled
+ case missingRequiredField(String)
+
+ public var errorDescription: String? {
+ switch self {
+ // Invite Code Related Errors
+ case .invalidInviteCode:
+ return "초대 코드가 잘못 되었습니다"
+ case .expiredInviteCode:
+ return "만료된 초대 코드입니다"
+
+ // Job Related Errors
+ case .invalidJob:
+ return "유효하지 않은 직무입니다"
+ case .jobNotSelected:
+ return "직무를 선택해주세요"
+ case .jobNotAvailable:
+ return "선택한 직무를 사용할 수 없습니다"
+
+ // Account Related Errors
+ case .accountAlreadyExists:
+ return "이미 존재하는 계정입니다"
+ case .accountCreationFailed:
+ return "계정 생성에 실패했습니다"
+
+ // Validation Errors
+ case .nameTooShort:
+ return "이름이 너무 짧습니다"
+ case .nameTooLong:
+ return "이름이 너무 깁니다"
+
+ // Network & Server Errors
+ case .networkError:
+ return "네트워크 연결을 확인해주세요"
+ case .serverError(let message):
+ return "서버 오류: \(message)"
+
+ // General Errors
+ case .unknownError(let message):
+ return "알 수 없는 오류가 발생했습니다: \(message)"
+ case .userCancelled:
+ return "사용자가 취소했습니다"
+ case .missingRequiredField(let field):
+ return "\(field)은(는) 필수 입력 항목입니다"
+ }
+ }
+
+ public var failureReason: String? {
+ switch self {
+ case .invalidInviteCode:
+ return "초대 코드 검증 실패"
+ case .invalidJob:
+ return "직무 검증 실패"
+ case .jobNotSelected:
+ return "직무 선택 실패"
+ case .networkError:
+ return "네트워크 연결 실패"
+ case .serverError:
+ return "서버 처리 실패"
+ default:
+ return nil
+ }
+ }
+
+ public var recoverySuggestion: String? {
+ switch self {
+ case .invalidInviteCode:
+ return "초대 코드를 다시 확인하거나 관리자에게 문의해주세요"
+ case .invalidJob:
+ return "유효한 직무를 선택해주세요"
+ case .jobNotSelected:
+ return "목록에서 직무를 선택해주세요"
+ case .jobNotAvailable:
+ return "다른 직무를 선택하거나 관리자에게 문의해주세요"
+ case .networkError:
+ return "인터넷 연결을 확인하고 다시 시도해주세요"
+ default:
+ return "문제가 지속되면 고객센터에 문의해주세요"
+ }
+ }
+}
+
+// MARK: - Convenience Methods
+
+public extension SignUpError {
+ static func from(_ error: Error) -> SignUpError {
+ if let signUpError = error as? SignUpError {
+ return signUpError
+ }
+ return .unknownError(error.localizedDescription)
+ }
+
+ /// 초대 코드 관련 에러인지 확인
+ var isInviteCodeError: Bool {
+ switch self {
+ case .invalidInviteCode, .expiredInviteCode:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// 직무 관련 에러인지 확인
+ var isJobError: Bool {
+ switch self {
+ case .invalidJob, .jobNotSelected, .jobNotAvailable:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// 네트워크 관련 에러인지 확인
+ var isNetworkError: Bool {
+ switch self {
+ case .networkError:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// 재시도 가능한 에러인지 확인
+ var isRetryable: Bool {
+ switch self {
+ case .networkError, .serverError:
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/OAuth/AppleOAuthPayload.swift b/Projects/Domain/Entity/Sources/OAuth/AppleOAuthPayload.swift
new file mode 100644
index 0000000..e91f38c
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OAuth/AppleOAuthPayload.swift
@@ -0,0 +1,27 @@
+//
+// AppleOAuthPayload.swift
+// Entity
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+
+public struct AppleOAuthPayload {
+ public let idToken: String
+ public let authorizationCode: String?
+ public let displayName: String?
+ public let nonce: String
+
+ public init(
+ idToken: String,
+ authorizationCode: String?,
+ displayName: String?,
+ nonce: String,
+ ) {
+ self.idToken = idToken
+ self.authorizationCode = authorizationCode
+ self.displayName = displayName
+ self.nonce = nonce
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/OAuth/AuthToken.swift b/Projects/Domain/Entity/Sources/OAuth/AuthToken.swift
new file mode 100644
index 0000000..14a1ab6
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OAuth/AuthToken.swift
@@ -0,0 +1,21 @@
+//
+// AuthToken.swift
+// Entity
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+
+public struct AuthTokens: Equatable, Hashable {
+ public let accessToken: String
+ public let refreshToken: String
+
+ public init(
+ accessToken: String,
+ refreshToken: String,
+ ) {
+ self.accessToken = accessToken
+ self.refreshToken = refreshToken
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/OAuth/GoogleOAuthPayload.swift b/Projects/Domain/Entity/Sources/OAuth/GoogleOAuthPayload.swift
new file mode 100644
index 0000000..da5a863
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OAuth/GoogleOAuthPayload.swift
@@ -0,0 +1,27 @@
+//
+// GoogleOAuthPayload.swift
+// Entity
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+
+public struct GoogleOAuthPayload {
+ public let idToken: String
+ public let accessToken: String?
+ public let authorizationCode: String?
+ public let displayName: String?
+
+ public init(
+ idToken: String,
+ accessToken: String?,
+ authorizationCode: String?,
+ displayName: String?
+ ) {
+ self.idToken = idToken
+ self.accessToken = accessToken
+ self.authorizationCode = authorizationCode
+ self.displayName = displayName
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift b/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift
new file mode 100644
index 0000000..a50c628
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift
@@ -0,0 +1,30 @@
+//
+// LoginEntity.swift
+// Entity
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+
+public struct LoginEntity: Equatable {
+ public let name: String
+ public let provider: SocialType
+ public let token: AuthTokens
+ public let isNewUser: Bool
+ public let email: String
+
+ public init(
+ name: String,
+ isNewUser: Bool,
+ provider: SocialType,
+ token: AuthTokens,
+ email: String
+ ) {
+ self.name = name
+ self.isNewUser = isNewUser
+ self.provider = provider
+ self.token = token
+ self.email = email
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/OAuth/SocialType.swift b/Projects/Domain/Entity/Sources/OAuth/SocialType.swift
new file mode 100644
index 0000000..936c419
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OAuth/SocialType.swift
@@ -0,0 +1,33 @@
+//
+// SocialType.swift
+// Entity
+//
+// Created by Wonji Suh on 3/19/26.
+//
+
+import Foundation
+
+public enum SocialType: String, CaseIterable, Identifiable, Hashable, Equatable {
+ case apple
+ case google
+
+ public var id: String { rawValue }
+
+ public var description: String {
+ switch self {
+ case .apple:
+ return "Apple"
+ case .google:
+ return "Google"
+ }
+ }
+
+ public var image: String {
+ switch self {
+ case .apple:
+ return "apple.logo"
+ case .google:
+ return "google"
+ }
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift
new file mode 100644
index 0000000..fff7cc0
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift
@@ -0,0 +1,35 @@
+//
+// UserSession.swift
+// Entity
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+public struct UserSession: Equatable {
+ public var name: String
+ public var email: String
+ public var provider: SocialType
+ public var authCode: String
+ public var mapType: ExternalMapType
+
+ public init(
+ name: String = "",
+ email: String = "",
+ provider: SocialType = .apple,
+ authCode: String = "",
+ mapType: ExternalMapType = .appleMap
+ ) {
+ self.name = name
+ self.email = email
+ self.provider = provider
+ self.authCode = authCode
+ self.mapType = mapType
+ }
+}
+
+public extension UserSession {
+ static let empty = UserSession()
+}
+
diff --git a/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift b/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift
new file mode 100644
index 0000000..d81d295
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift
@@ -0,0 +1,51 @@
+//
+// ExternalMap.swift
+// Entity
+//
+// Created by Wonji Suh on 3/22/26.
+//
+
+import Foundation
+
+public enum ExternalMapType: String, CaseIterable, Identifiable, Hashable, Equatable {
+ case googleMap
+ case naverMap
+ case appleMap
+
+ public var id: String { rawValue }
+
+ public var description: String {
+ switch self {
+ case .appleMap:
+ return "애플지도"
+
+ case .googleMap:
+ return "Google Maps"
+
+ case .naverMap:
+ return "네이버지도"
+ }
+ }
+
+ public var type: String {
+ switch self {
+ case .googleMap:
+ return "google"
+ case .naverMap:
+ return "naver"
+ case .appleMap:
+ return "apple"
+ }
+ }
+
+ public var image: String {
+ switch self {
+ case .appleMap:
+ return "appleMap"
+ case .googleMap:
+ return "goolgeMap"
+ case .naverMap:
+ return "naverMap"
+ }
+ }
+}
diff --git a/Projects/Domain/Entity/Sources/SignUp/SignUpInput.swift b/Projects/Domain/Entity/Sources/SignUp/SignUpInput.swift
new file mode 100644
index 0000000..7aa805d
--- /dev/null
+++ b/Projects/Domain/Entity/Sources/SignUp/SignUpInput.swift
@@ -0,0 +1,30 @@
+//
+// SignUpInput.swift
+// Entity
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+
+public struct SignUpInput {
+ public let name: String
+ public let provider: SocialType
+ public let mapType: ExternalMapType
+ public let authCode: String
+ public let email: String
+
+ public init(
+ name: String,
+ provider: SocialType,
+ mapType: ExternalMapType,
+ authCode: String,
+ email: String
+ ) {
+ self.name = name
+ self.provider = provider
+ self.mapType = mapType
+ self.authCode = authCode
+ self.email = email
+ }
+}
diff --git a/Projects/Domain/UseCase/Project.swift b/Projects/Domain/UseCase/Project.swift
index 96544a7..2e6cc37 100644
--- a/Projects/Domain/UseCase/Project.swift
+++ b/Projects/Domain/UseCase/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "UseCase",
bundleId: .appBundleID(name: ".UseCase"),
product: .staticFramework,
diff --git a/Projects/Domain/UseCase/Sources/Direction/GetRouteUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift
similarity index 81%
rename from Projects/Domain/UseCase/Sources/Direction/GetRouteUseCaseImpl.swift
rename to Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift
index 45b5ae4..85fe2d4 100644
--- a/Projects/Domain/UseCase/Sources/Direction/GetRouteUseCaseImpl.swift
+++ b/Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift
@@ -15,9 +15,7 @@ import ComposableArchitecture
import LogMacro
/// 경로 검색 비즈니스 로직을 처리하는 UseCase
-public struct GetRouteUseCaseImpl: DirectionInterface {
-
-
+public struct RouteUseCaseImpl: DirectionInterface {
@Dependency(\.directionRepository) var repository
@@ -61,16 +59,16 @@ public struct GetRouteUseCaseImpl: DirectionInterface {
}
-extension GetRouteUseCaseImpl: DependencyKey {
- public static var liveValue = GetRouteUseCaseImpl()
- public static var testValue = GetRouteUseCaseImpl()
+extension RouteUseCaseImpl: DependencyKey {
+ public static var liveValue = RouteUseCaseImpl()
+ public static var testValue = RouteUseCaseImpl()
public static var previewValue = liveValue
}
public extension DependencyValues {
- var getRouteUseCase: GetRouteUseCaseImpl {
- get { self[GetRouteUseCaseImpl.self] }
- set { self[GetRouteUseCaseImpl.self] = newValue }
+ var getRouteUseCase: RouteUseCaseImpl {
+ get { self[RouteUseCaseImpl.self] }
+ set { self[RouteUseCaseImpl.self] = newValue }
}
}
diff --git a/Projects/Domain/UseCase/Sources/Manger/KeychainManager.swift b/Projects/Domain/UseCase/Sources/Manger/KeychainManager.swift
index 75671d2..7c49061 100644
--- a/Projects/Domain/UseCase/Sources/Manger/KeychainManager.swift
+++ b/Projects/Domain/UseCase/Sources/Manger/KeychainManager.swift
@@ -27,68 +27,6 @@ public actor KeychainManager: KeychainManagingInterface {
self.accessGroup = accessGroup
}
- // MARK: - Legacy Sync API (Backward Compatibility)
-
- public nonisolated func save(accessToken: String, refreshToken: String) {
- Task { [weak self] in
- guard let self = self else { return }
- do {
- try await self.save(accessToken: accessToken, refreshToken: refreshToken)
- } catch {
- // TODO: Add proper logging in production
- print("⚠️ Failed to save tokens: \(error)")
- }
- }
- }
-
- public nonisolated func saveAccessToken(_ token: String) {
- Task { [weak self] in
- guard let self = self else { return }
- do {
- try await self.saveAccessToken(token)
- } catch {
- // TODO: Add proper logging in production
- print("⚠️ Failed to save access token: \(error)")
- }
- }
- }
-
- public nonisolated func saveRefreshToken(_ token: String) {
- Task { [weak self] in
- guard let self = self else { return }
- do {
- try await self.saveRefreshToken(token)
- } catch {
- // TODO: Add proper logging in production
- print("⚠️ Failed to save refresh token: \(error)")
- }
- }
- }
-
- public nonisolated func accessToken() -> String? {
- // ⚠️ Sync access - use async version for better safety
- return legacyRead(for: Key.accessToken)
- }
-
- public nonisolated func refreshToken() -> String? {
- // ⚠️ Sync access - use async version for better safety
- return legacyRead(for: Key.refreshToken)
- }
-
- public nonisolated func clear() {
- Task { [weak self] in
- guard let self = self else { return }
- do {
- try await self.clear()
- } catch {
- // TODO: Add proper logging in production
- print("⚠️ Failed to clear keychain: \(error)")
- }
- }
- }
-
- // MARK: - Modern Async API (iOS 17+)
-
public func save(accessToken: String, refreshToken: String) async throws {
try save(accessToken, for: Key.accessToken)
try save(refreshToken, for: Key.refreshToken)
@@ -177,28 +115,6 @@ public actor KeychainManager: KeychainManagingInterface {
return query
}
- // MARK: - Legacy Support (nonisolated)
-
- private nonisolated func legacyRead(for key: String) -> String? {
- var query: [CFString: Any] = [
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: service,
- kSecAttrAccount: key,
- kSecReturnData: true,
- kSecMatchLimit: kSecMatchLimitOne
- ]
-
- if let accessGroup = accessGroup {
- query[kSecAttrAccessGroup] = accessGroup
- }
-
- 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)
- }
}
// MARK: - Error Types
diff --git a/Projects/Domain/UseCase/Sources/OAuth/Dependencies+OAuth.swift b/Projects/Domain/UseCase/Sources/OAuth/Dependencies+OAuth.swift
new file mode 100644
index 0000000..e468dea
--- /dev/null
+++ b/Projects/Domain/UseCase/Sources/OAuth/Dependencies+OAuth.swift
@@ -0,0 +1,25 @@
+//
+// Dependencies+OAuth.swift
+// UseCase
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Dependencies
+import DomainInterface
+
+// MARK: - Apple OAuth Provider Registration
+
+extension AppleOAuthProviderDependency {
+ public static var liveValue: AppleOAuthProviderInterface {
+ AppleOAuthProvider()
+ }
+}
+
+// MARK: - Google OAuth Provider Registration
+
+extension GoogleOAuthProviderDependency {
+ public static var liveValue: GoogleOAuthProviderInterface {
+ GoogleOAuthProvider()
+ }
+}
diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift
new file mode 100644
index 0000000..85533ee
--- /dev/null
+++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift
@@ -0,0 +1,42 @@
+//
+// AppleOAuthProvider.swift
+// UseCase
+//
+// Created by Wonji Suh on 12/29/25.
+//
+
+import Foundation
+import Dependencies
+import LogMacro
+import AuthenticationServices
+@preconcurrency import Entity
+import DomainInterface
+import Sharing
+
+public struct AppleOAuthProvider: AppleOAuthProviderInterface, Sendable {
+ @Dependency(\.appleOAuthRepository) private var appleRepository: AppleOAuthInterface
+ public init() {}
+
+ public func signInWithCredential(
+ credential: ASAuthorizationAppleIDCredential,
+ nonce: String
+ ) async throws -> AppleOAuthPayload {
+ let payload = try await appleRepository.signInWithCredential(credential, nonce: nonce)
+ Log.info("Apple sign-in completed through repository with credential")
+ return payload
+ }
+
+ public func signIn() async throws -> AppleOAuthPayload {
+ let payload = try await appleRepository.signIn()
+ Log.info("Apple sign-in completed through repository (direct)")
+ return payload
+ }
+
+ private func formatDisplayName(_ components: PersonNameComponents?) -> String? {
+ guard let components else { return nil }
+ let formatter = PersonNameComponentsFormatter()
+ let name = formatter.string(from: components).trimmingCharacters(in: .whitespacesAndNewlines)
+ return name.isEmpty ? nil : name
+ }
+}
+
diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift
new file mode 100644
index 0000000..97d3b3e
--- /dev/null
+++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift
@@ -0,0 +1,25 @@
+//
+// GoogleOAuthProvider.swift
+// UseCase
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import DomainInterface
+import Entity
+
+import ComposableArchitecture
+import LogMacro
+
+public struct GoogleOAuthProvider: GoogleOAuthProviderInterface, Sendable {
+ @Dependency(\.googleOAuthRepository) var repository
+
+ public init() {
+ }
+
+ public func signInWithToken(token: String) async throws -> GoogleOAuthPayload {
+ let payload = try await repository.signIn()
+ Log.info("google sign-in completed through repository with credential \(payload.displayName)")
+ return payload
+ }
+}
diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift
new file mode 100644
index 0000000..d2ec4bb
--- /dev/null
+++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift
@@ -0,0 +1,176 @@
+//
+// UnifiedOAuthUseCase.swift
+// UseCase
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import Foundation
+import Dependencies
+import AuthenticationServices
+@preconcurrency import Entity
+import DomainInterface
+import Sharing
+import LogMacro
+
+/// 통합 OAuth UseCase - 로그인/회원가입 플로우를 하나로 통합
+public struct UnifiedOAuthUseCase {
+ @Dependency(\.authRepository) private var authRepository: AuthInterface
+ @Dependency(\.appleOAuthProvider) private var appleProvider: AppleOAuthProviderInterface
+ @Dependency(\.googleOAuthProvider) private var googleProvider: GoogleOAuthProviderInterface
+ @Dependency(\.keychainManager) private var keychainManager: KeychainManaging
+ @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty
+ @Shared(.appStorage("appleUserName")) var savedAppleUserName: String?
+
+ public init() {}
+}
+
+// MARK: - Public Interface
+
+public extension UnifiedOAuthUseCase {
+
+ /// 통합 소셜 로그인 처리
+ func socialLogin(
+ with socialType: SocialType,
+ appleCredential: ASAuthorizationAppleIDCredential? = nil,
+ nonce: String? = nil,
+ googleToken: String? = nil
+ ) async throws -> LoginEntity {
+ switch socialType {
+ case .apple:
+ guard let credential = appleCredential, let nonce = nonce else {
+ throw AuthError.invalidCredential("Apple 로그인에 필요한 credential 또는 nonce가 없습니다")
+ }
+ return try await appleLogin(credential: credential, nonce: nonce)
+ case .google:
+ guard let token = googleToken else {
+ throw AuthError.invalidCredential("Google 로그인에 필요한 token이 없습니다")
+ }
+ return try await googleLogin(token: token)
+ }
+ }
+
+ /// Apple 로그인 처리
+ func appleLogin(
+ credential: ASAuthorizationAppleIDCredential,
+ nonce: String
+ ) async throws -> LoginEntity {
+ let payload = try await appleProvider.signInWithCredential(
+ credential: credential,
+ nonce: nonce
+ )
+ Log.debug("apple authcode", payload.authorizationCode)
+
+ // Apple 로그인 시 이름 저장 로직 개선
+ let userName: String = {
+ if let displayName = payload.displayName, !displayName.isEmpty {
+ // 새로운 이름이 있으면 UserDefaults에 저장
+ self.$savedAppleUserName.withLock { $0 = displayName }
+ return displayName
+ } else {
+ // 이름이 없으면 이전에 저장된 이름 사용, 그것도 없으면 빈 문자열
+ return self.savedAppleUserName ?? ""
+ }
+ }()
+
+ self.$userSession.withLock {
+ $0.name = userName
+ $0.provider = .apple
+ $0.authCode = payload.authorizationCode ?? ""
+ }
+
+
+ let loginEntity = try await authRepository.login(
+ provider: .apple,
+ token: payload.idToken ?? ""
+ )
+
+ print("애플 코드 \(payload.authorizationCode ?? "")")
+
+ self.$userSession.withLock {
+ $0.name = savedAppleUserName ?? ""
+ $0.provider = .apple
+ $0.email = loginEntity.email
+ $0.authCode = payload.authorizationCode ?? ""
+ }
+
+ try await keychainManager.save(
+ accessToken: loginEntity.token.accessToken,
+ refreshToken: loginEntity.token.refreshToken
+ )
+
+ // AuthSessionManager의 credential도 업데이트
+ authRepository.updateSessionCredential(with: loginEntity.token)
+
+ return loginEntity
+ }
+
+ /// Google 로그인 처리
+ func googleLogin(
+ token: String
+ ) async throws -> LoginEntity {
+ let payload = try await googleProvider.signInWithToken(token: token)
+ self.$userSession.withLock { $0.authCode = payload.authorizationCode ?? "" }
+ self.$userSession.withLock {
+ $0.name = payload.displayName ?? ""
+ $0.provider = .google
+ $0.authCode = payload.authorizationCode ?? ""
+ }
+ let loginEntity = try await authRepository.login(
+ provider: .google,
+ token: payload.idToken
+ )
+
+ self.$userSession.withLock {
+ $0.email = loginEntity.email
+ }
+
+
+ try await keychainManager.save(
+ accessToken: loginEntity.token.accessToken,
+ refreshToken: loginEntity.token.refreshToken
+ )
+
+
+ // AuthSessionManager의 credential도 업데이트
+ authRepository.updateSessionCredential(with: loginEntity.token)
+ return loginEntity
+ }
+
+ /// OAuth 플로우 처리 (TCA용)
+ func processOAuthFlow(
+ with socialType: SocialType,
+ appleCredential: ASAuthorizationAppleIDCredential? = nil,
+ nonce: String? = nil,
+ googleToken: String? = nil
+ ) async -> Result {
+ do {
+ let result = try await socialLogin(
+ with: socialType,
+ appleCredential: appleCredential,
+ nonce: nonce,
+ googleToken: googleToken
+ )
+ return .success(result)
+ } catch let error as AuthError {
+ return .failure(error)
+ } catch {
+ return .failure(.unknownError(error.localizedDescription))
+ }
+ }
+}
+
+// MARK: - Dependencies Registration
+
+extension UnifiedOAuthUseCase: DependencyKey {
+ public static let liveValue = UnifiedOAuthUseCase()
+ public static let testValue = UnifiedOAuthUseCase()
+ public static let previewValue = UnifiedOAuthUseCase()
+}
+
+extension DependencyValues {
+ public var unifiedOAuthUseCase: UnifiedOAuthUseCase {
+ get { self[UnifiedOAuthUseCase.self] }
+ set { self[UnifiedOAuthUseCase.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift
new file mode 100644
index 0000000..06eb941
--- /dev/null
+++ b/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift
@@ -0,0 +1,62 @@
+//
+// SignUpUseCaseImpl.swift
+// UseCase
+//
+// Created by Wonji Suh on 3/23/26.
+//
+
+import DomainInterface
+import Entity
+
+import ComposableArchitecture
+
+public protocol SignUpUseCaseInterface: Sendable {
+ func registerUser(
+ userSession: UserSession
+ ) async throws -> LoginEntity
+}
+
+public struct SignUpUseCaseImpl: SignUpUseCaseInterface {
+ @Dependency(\.signUpRepository) var repository
+ @Dependency(\.keychainManager) private var keychainManager
+
+ public init() {
+
+ }
+
+ public func registerUser(
+ userSession: UserSession
+ ) async throws -> LoginEntity {
+ let input = SignUpInput(
+ name: userSession.name,
+ provider: userSession.provider,
+ mapType: userSession.mapType,
+ authCode: userSession.authCode,
+ email: userSession.email
+ )
+
+ let signUpUser = try await repository.registerUser(input: input)
+
+ try await keychainManager.save(
+ accessToken: signUpUser.token.accessToken,
+ refreshToken: signUpUser.token.refreshToken
+ )
+
+ return signUpUser
+
+ }
+
+}
+
+extension SignUpUseCaseImpl: DependencyKey {
+ static public var liveValue: SignUpUseCaseInterface = SignUpUseCaseImpl()
+ static public var testValue: SignUpUseCaseInterface = SignUpUseCaseImpl()
+ static public var previewValue: SignUpUseCaseInterface = liveValue
+}
+
+public extension DependencyValues {
+ var signUpUseCase: SignUpUseCaseInterface {
+ get { self[SignUpUseCaseImpl.self] }
+ set { self[SignUpUseCaseImpl.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/UseCase/Tests/Sources/Test.swift b/Projects/Domain/UseCase/Tests/Sources/Test.swift
index a9c810e..b308401 100644
--- a/Projects/Domain/UseCase/Tests/Sources/Test.swift
+++ b/Projects/Domain/UseCase/Tests/Sources/Test.swift
@@ -1,8 +1,227 @@
//
-// base.swift
-// DDDAttendance
+// RouteUseCaseImplTests.swift
+// UseCaseTests
//
-// Created by Roy on 2025-09-04
-// Copyright © 2025 DDD , Ltd. All rights reserved.
+// Created by Wonja Suh on 2026-03-12
+// Copyright © 2026 TimeSpot, Ltd., All rights reserved.
//
+import Testing
+import CoreLocation
+import Dependencies
+@testable import UseCase
+@testable import Entity
+@testable import DomainInterface
+
+@Suite("길찾기 UseCase 테스트", .tags(.domain, .usecase, .route))
+struct RouteUseCaseImplTests {
+
+ // MARK: - Test Data
+ let testStartCoord = CLLocationCoordinate2D(latitude: 37.497942, longitude: 127.027621) // 강남역
+ let testDestCoord = CLLocationCoordinate2D(latitude: 37.556785, longitude: 126.923011) // 홍대입구역
+
+ let mockRouteInfo = RouteInfo(
+ paths: [
+ CLLocationCoordinate2D(latitude: 37.497942, longitude: 127.027621),
+ CLLocationCoordinate2D(latitude: 37.527, longitude: 127.025),
+ CLLocationCoordinate2D(latitude: 37.556785, longitude: 126.923011)
+ ],
+ distance: 1500,
+ duration: 20
+ )
+
+ // MARK: - Mock DirectionRepository
+ final class MockDirectionRepository: DirectionInterface, @unchecked Sendable {
+ var shouldThrowError: DirectionError?
+ var mockResult: RouteInfo?
+ var callHistory: [(CLLocationCoordinate2D, CLLocationCoordinate2D, RouteOption)] = []
+
+ func getRoute(
+ from start: CLLocationCoordinate2D,
+ to destination: CLLocationCoordinate2D,
+ option: RouteOption
+ ) async throws -> RouteInfo {
+ // 호출 기록 저장
+ callHistory.append((start, destination, option))
+
+ // 에러 시뮬레이션
+ if let error = shouldThrowError {
+ throw error
+ }
+
+ // Mock 결과 반환
+ guard let result = mockResult else {
+ throw DirectionError.noRoute
+ }
+
+ return result
+ }
+ }
+
+ // MARK: - Success Cases
+ @Test("UseCase execute 메서드 정상 동작 확인", .tags(.success, .usecase))
+ func executeSuccess() async throws {
+ // Given
+ let mockRepository = MockDirectionRepository()
+ mockRepository.mockResult = mockRouteInfo
+
+ let useCase = withDependencies {
+ $0.directionRepository = mockRepository
+ } operation: {
+ RouteUseCaseImpl()
+ }
+
+ // When
+ let result = try await useCase.execute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then
+ #expect(result.distance == mockRouteInfo.distance)
+ #expect(result.duration == mockRouteInfo.duration)
+ #expect(result.paths.count == mockRouteInfo.paths.count)
+ #expect(mockRepository.callHistory.count == 1)
+
+ let lastCall = mockRepository.callHistory.last!
+ #expect(lastCall.2 == .walking)
+ }
+
+ @Test("getRoute 메서드 Repository 위임 확인", .tags(.success, .usecase))
+ func getRouteDelegation() async throws {
+ // Given
+ let mockRepository = MockDirectionRepository()
+ mockRepository.mockResult = mockRouteInfo
+
+ let useCase = withDependencies {
+ $0.directionRepository = mockRepository
+ } operation: {
+ RouteUseCaseImpl()
+ }
+
+ // When
+ let result = try await useCase.getRoute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+
+ // Then
+ #expect(result == mockRouteInfo)
+ #expect(mockRepository.callHistory.count == 1)
+ }
+
+ @Test("다양한 경로 옵션 처리 확인", .tags(.success, .route))
+ func differentRouteOptions() async throws {
+ // Given
+ let mockRepository = MockDirectionRepository()
+ mockRepository.mockResult = mockRouteInfo
+
+ let useCase = withDependencies {
+ $0.directionRepository = mockRepository
+ } operation: {
+ RouteUseCaseImpl()
+ }
+
+ let testOptions: [RouteOption] = [.walking, .trafast, .traoptimal]
+
+ // When
+ for option in testOptions {
+ let _ = try await useCase.execute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: option
+ )
+ }
+
+ // Then
+ #expect(mockRepository.callHistory.count == testOptions.count)
+ for (index, option) in testOptions.enumerated() {
+ #expect(mockRepository.callHistory[index].2 == option)
+ }
+ }
+
+ // MARK: - Error Cases
+ @Test("네트워크 에러 전파 확인", .tags(.error, .usecase))
+ func networkErrorPropagation() async throws {
+ // Given
+ let mockRepository = MockDirectionRepository()
+ mockRepository.shouldThrowError = DirectionError.networkError("Test network error")
+
+ let useCase = withDependencies {
+ $0.directionRepository = mockRepository
+ } operation: {
+ RouteUseCaseImpl()
+ }
+
+ // When & Then
+ await #expect(throws: DirectionError.self) {
+ try await useCase.execute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+ }
+ }
+
+ @Test("경로 없음 에러 처리 확인", .tags(.error, .route))
+ func noRouteError() async throws {
+ // Given
+ let mockRepository = MockDirectionRepository()
+ mockRepository.shouldThrowError = DirectionError.noRoute
+
+ let useCase = withDependencies {
+ $0.directionRepository = mockRepository
+ } operation: {
+ RouteUseCaseImpl()
+ }
+
+ // When & Then
+ await #expect(throws: DirectionError.noRoute) {
+ try await useCase.execute(
+ from: testStartCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+ }
+ }
+
+ @Test("잘못된 좌표 에러 처리 확인", .tags(.error, .route))
+ func invalidCoordinatesError() async throws {
+ // Given
+ let mockRepository = MockDirectionRepository()
+ mockRepository.shouldThrowError = DirectionError.invalidCoordinates
+
+ let useCase = withDependencies {
+ $0.directionRepository = mockRepository
+ } operation: {
+ RouteUseCaseImpl()
+ }
+
+ let invalidCoord = CLLocationCoordinate2D(latitude: 999, longitude: 999)
+
+ // When & Then
+ await #expect(throws: DirectionError.invalidCoordinates) {
+ try await useCase.execute(
+ from: invalidCoord,
+ to: testDestCoord,
+ option: .walking
+ )
+ }
+ }
+
+ // MARK: - Dependencies Tests
+ @Test("Dependencies 주입 정상 동작 확인", .tags(.dependencies))
+ func dependencies주입_정상동작() async throws {
+ // Given & When & Then - withDependencies 블록으로 의존성 주입 테스트
+ let result = withDependencies {
+ $0.directionRepository = MockDirectionRepository()
+ } operation: {
+ let useCase = RouteUseCaseImpl()
+ return useCase
+ }
+
+ #expect(result != nil)
+ }
+}
diff --git a/Projects/Domain/UseCase/Tests/Sources/TestTags.swift b/Projects/Domain/UseCase/Tests/Sources/TestTags.swift
new file mode 100644
index 0000000..1720560
--- /dev/null
+++ b/Projects/Domain/UseCase/Tests/Sources/TestTags.swift
@@ -0,0 +1,23 @@
+//
+// TestTags.swift
+// UseCaseTests
+//
+// Created by Wonja Suh on 2026-03-12
+// Copyright © 2026 TimeSpot, Ltd., All rights reserved.
+//
+
+import Testing
+
+// MARK: - Swift Testing Tags
+extension Tag {
+ @Tag static var domain: Self
+ @Tag static var usecase: Self
+ @Tag static var route: Self
+ @Tag static var success: Self
+ @Tag static var error: Self
+ @Tag static var dependencies: Self
+ @Tag static var integration: Self
+ @Tag static var unit: Self
+ @Tag static var repository: Self
+ @Tag static var entity: Self
+}
\ No newline at end of file
diff --git a/Projects/Network/Foundations/Project.swift b/Projects/Network/Foundations/Project.swift
index 473a952..7758650 100644
--- a/Projects/Network/Foundations/Project.swift
+++ b/Projects/Network/Foundations/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Foundations",
bundleId: .appBundleID(name: ".Foundations"),
product: .staticFramework,
diff --git a/Projects/Network/Networks/Project.swift b/Projects/Network/Networks/Project.swift
index b4b2035..8e8e12a 100644
--- a/Projects/Network/Networks/Project.swift
+++ b/Projects/Network/Networks/Project.swift
@@ -5,7 +5,7 @@ import ProjectTemplatePlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Networks",
bundleId: .appBundleID(name: ".Networks"),
product: .staticFramework,
diff --git a/Projects/Network/ThirdPartys/Project.swift b/Projects/Network/ThirdPartys/Project.swift
index 91c4eb0..61cf4d5 100644
--- a/Projects/Network/ThirdPartys/Project.swift
+++ b/Projects/Network/ThirdPartys/Project.swift
@@ -5,7 +5,7 @@ import ProjectTemplatePlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "ThirdPartys",
bundleId: .appBundleID(name: ".ThirdPartys"),
product: .staticFramework,
diff --git a/Projects/Presentation/Auth/AuthTests/Sources/Test.swift b/Projects/Presentation/Auth/AuthTests/Sources/Test.swift
new file mode 100644
index 0000000..8f96df2
--- /dev/null
+++ b/Projects/Presentation/Auth/AuthTests/Sources/Test.swift
@@ -0,0 +1,8 @@
+//
+// base.swift
+// DDDAttendance
+//
+// Created by Roy on 2026-03-17
+// Copyright © 2026 DDD , Ltd. All rights reserved.
+//
+
diff --git a/Projects/Presentation/Auth/Project.swift b/Projects/Presentation/Auth/Project.swift
new file mode 100644
index 0000000..f3ddf7d
--- /dev/null
+++ b/Projects/Presentation/Auth/Project.swift
@@ -0,0 +1,22 @@
+import Foundation
+import ProjectDescription
+import DependencyPlugin
+import ProjectTemplatePlugin
+import ProjectTemplatePlugin
+import DependencyPackagePlugin
+
+
+let project = Project.makeModule(
+ name: "Auth",
+ bundleId: .appBundleID(name: ".Auth"),
+ product: .staticFramework,
+ settings: .settings(),
+ dependencies: [
+ .Domain(implements: .UseCase),
+ .Shared(implements: .Shared),
+ .SPM.composableArchitecture,
+ .SPM.tcaCoordinator,
+ .Presentation(implements: .OnBoarding)
+ ],
+ sources: ["Sources/**"]
+)
diff --git a/Projects/Presentation/Auth/Sources/Compoents/SocialLoginButton.swift b/Projects/Presentation/Auth/Sources/Compoents/SocialLoginButton.swift
new file mode 100644
index 0000000..512146d
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/Compoents/SocialLoginButton.swift
@@ -0,0 +1,120 @@
+//
+// SocialLoginButton.swift
+// Auth
+//
+// Created by Wonji Suh on 3/19/26.
+//
+
+import SwiftUI
+import AuthenticationServices
+
+import DesignSystem
+import Entity
+
+import ComposableArchitecture
+
+public struct SocialLoginButton: View {
+ @State var store: StoreOf
+ let type: SocialType
+ let onTap: () -> Void
+ @State private var isPressed = false
+
+ public var body: some View {
+ switch type {
+ case .apple:
+ appleLoginButton(type: type) { request in
+ store.send(.async(.prepareAppleRequest(request)))
+ } onCompletion: { result in
+ store.send(.async(.appleLogin(result, nonce: store.nonce)))
+ }
+
+ case .google:
+ googleLoginButton(type: type, onTap: onTap)
+ }
+ }
+}
+
+
+extension SocialLoginButton {
+
+ @ViewBuilder
+ private func appleLoginButton(
+ type: SocialType,
+ request: @escaping (ASAuthorizationAppleIDRequest) -> Void,
+ onCompletion: @escaping (Result) -> Void
+ ) -> some View {
+ ZStack {
+ SignInWithAppleButton(.signIn) { req in
+ request(req)
+ } onCompletion: { result in
+ onCompletion(result)
+ }
+ .frame(height: 60)
+ .clipShape(Capsule())
+ .allowsHitTesting(true)
+
+ RoundedRectangle(cornerRadius: 20)
+ .fill(.black)
+ .frame(height: 60)
+ .overlay {
+ HStack(spacing: .zero) {
+ Spacer()
+
+ Image(systemName: type.image)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundStyle(.gray100)
+
+ Spacer()
+ .frame(width: 8)
+
+ Text("\(type.description)로 시작하기")
+ .pretendardCustomFont(textStyle: .titleBold)
+ .foregroundStyle(.gray100)
+ Spacer()
+ }
+ }
+ .allowsHitTesting(false)
+ .allowsTightening(false)
+ .clipShape(Capsule())
+ }
+ .scaleEffect(isPressed ? 0.95 : 1.0)
+ .animation(.spring(response: 0.52, dampingFraction: 0.94, blendDuration: 0.14), value: isPressed)
+ }
+
+
+ @ViewBuilder
+ fileprivate func googleLoginButton(
+ type: SocialType,
+ onTap: @escaping () -> Void
+ ) -> some View {
+ VStack {
+ RoundedRectangle(cornerRadius: 30)
+ .stroke(.black.opacity(0.5), lineWidth: 1)
+ .frame(height: 60)
+ .overlay {
+ HStack(spacing: .zero) {
+ Spacer()
+
+ Image(assetName: type.image)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+
+ Spacer()
+ .frame(width: 8)
+
+ Text("\(type.description)로 시작하기")
+ .pretendardCustomFont(textStyle: .titleBold)
+ .foregroundStyle(.black)
+
+ Spacer()
+ }
+ }
+ .clipShape(Capsule())
+ .contentShape(Capsule())
+ .onTapGesture { onTap() }
+ }
+ }
+}
diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift
new file mode 100644
index 0000000..5b98e5e
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift
@@ -0,0 +1,158 @@
+//
+// AuthCoordinator.swift
+// Auth
+//
+// Created by Wonji Suh on 3/17/26.
+//
+
+import ComposableArchitecture
+import TCACoordinators
+import OnBoarding
+
+@Reducer
+public struct AuthCoordinator {
+
+ public init(){}
+
+ @ObservableState
+ public struct State: Equatable {
+ var routes: [Route]
+
+ public init() {
+ self.routes = [.root(.login(.init()), withNavigation: true)]
+ }
+ }
+
+ public enum Action {
+ case router(IndexedRouterActionOf)
+ case view(View)
+ case async(AsyncAction)
+ case inner(InnerAction)
+ case navigation(NavigationAction)
+ }
+
+ // MARK: - ViewAction
+ @CasePathable
+ public enum View {
+ case backAction
+ case backToRootAction
+ }
+
+ // MARK: - AsyncAction 비동기 처리 액션
+
+ public enum AsyncAction: Equatable {
+
+ }
+
+ // MARK: - 앱내에서 사용하는 액션
+ public enum InnerAction: Equatable {
+
+ }
+
+ // MARK: - NavigationAction
+ public enum NavigationAction: Equatable {
+ case presentMain
+ }
+
+ public var body: some Reducer {
+ Reduce { state, action in
+ switch action {
+ case .router(let routeAction):
+ return routerAction(state: &state, action: routeAction)
+
+ case .view(let viewAction):
+ return handleViewAction(state: &state, action: viewAction)
+
+ case .async(let asyncAction):
+ return handleAsyncAction(state: &state, action: asyncAction)
+
+ case .inner(let innerAction):
+ return handleInnerAction(state: &state, action: innerAction)
+
+ case .navigation(let navigationAction):
+ return handleNavigationAction(state: &state, action: navigationAction)
+ }
+ }
+ .forEachRoute(\.routes, action: \.router)
+ }
+
+}
+
+extension AuthCoordinator {
+ private func routerAction(
+ state: inout State,
+ action: IndexedRouterActionOf
+ ) -> Effect {
+ switch action {
+
+ case .routeAction(id: _, action: .login(.delegate(.presentOnBoarding))):
+ return .run { send in
+ await MainActor.run {
+ state.routes.push(.onBoarding(.init()))
+ }
+ }
+
+ case .routeAction(id: _, action: .login(.delegate(.presentMain))):
+ return .send(.navigation(.presentMain))
+
+ case .routeAction(id: _, action: .onBoarding(.navigation(.presentMain))):
+ return .send(.navigation(.presentMain))
+
+
+ default:
+ return .none
+ }
+ }
+
+ private func handleViewAction(
+ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .backAction:
+ state.routes.goBack()
+ return .none
+
+ case .backToRootAction:
+ state.routes.goBackToRoot()
+ return .none
+ }
+ }
+
+ private func handleNavigationAction(
+ state: inout State,
+ action: NavigationAction
+ ) -> Effect {
+ switch action {
+ case .presentMain:
+ return .none
+ }
+
+ }
+
+ private func handleAsyncAction(
+ state: inout State,
+ action: AsyncAction
+ ) -> Effect {
+
+ }
+
+ private func handleInnerAction(
+ state: inout State,
+ action: InnerAction
+ ) -> Effect {
+
+ }
+}
+
+extension AuthCoordinator {
+ @Reducer
+ public enum AuthScreen {
+ case login(LoginFeature)
+ case onBoarding(OnBoardingCoordinator)
+ }
+}
+
+// MARK: - AuthScreen State Equatable & Hashable
+extension AuthCoordinator.AuthScreen.State: Equatable {}
+extension AuthCoordinator.AuthScreen.State: Hashable {}
diff --git a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift
new file mode 100644
index 0000000..2d8ec79
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift
@@ -0,0 +1,35 @@
+//
+// AuthCoordinatorView.swift
+// Auth
+//
+// Created by Wonji Suh on 3/17/26.
+//
+
+import SwiftUI
+
+import ComposableArchitecture
+import TCACoordinators
+import OnBoarding
+
+public struct AuthCoordinatorView: View {
+ @Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ TCARouter(store.scope(state: \.routes, action: \.router)) { screens in
+ switch screens.case {
+ case .login(let loginStore):
+ LoginView(store: loginStore)
+ .navigationBarBackButtonHidden()
+
+ case .onBoarding(let onBoardingStore):
+ OnBoardingCoordinatorView(store: onBoardingStore)
+ .navigationBarBackButtonHidden()
+ .transition(.opacity.combined(with: .scale(scale: 0.98)))
+ }
+ }
+ }
+}
diff --git a/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift
new file mode 100644
index 0000000..0b77221
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift
@@ -0,0 +1,291 @@
+//
+// LoginFeature.swift
+// Auth
+//
+// Created by Wonji Suh on 3/17/26.
+//
+
+import Foundation
+import AuthenticationServices
+
+import Entity
+import DesignSystem
+import Utill
+
+import ComposableArchitecture
+import LogMacro
+
+
+
+@Reducer
+public struct LoginFeature {
+ public init() {}
+
+ @ObservableState
+ public struct State: Equatable, Hashable {
+ @Presents var destination: Destination.State?
+ var nonce: String = ""
+ var appleAccessToken: String = ""
+ var appleLoginFullName: ASAuthorizationAppleIDCredential?
+ var loginEntity: LoginEntity?
+ var currentSocialType: SocialType?
+ @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty
+
+ public init() {}
+ }
+
+ public enum Action: ViewAction, BindableAction {
+ case binding(BindingAction)
+ case destination(PresentationAction)
+ case view(View)
+ case async(AsyncAction)
+ case inner(InnerAction)
+ case delegate(DelegateAction)
+
+ }
+
+ //MARK: - ViewAction
+ @CasePathable
+ public enum View {
+ case signInWithSocial(social: SocialType)
+ }
+
+ @Reducer
+ public enum Destination {
+ case termsService(TermsAgreementFeature)
+ }
+
+ //MARK: - AsyncAction 비동기 처리 액션
+ public enum AsyncAction {
+ case prepareAppleRequest(ASAuthorizationAppleIDRequest)
+ case appleLogin(Result, nonce: String)
+ case login(socialType: SocialType)
+ }
+
+ //MARK: - 앱내에서 사용하는 액션
+ public enum InnerAction: Equatable {
+ case clearDestination
+ case loginResponse(Result)
+ }
+
+ //MARK: - DelegateAction
+ public enum DelegateAction: Equatable {
+ case presentTermsAgreement
+ case presentPrivacyWeb
+ case presentOnBoarding
+ case presentMain
+
+ }
+
+ nonisolated enum CancelID: Hashable {
+ case googleOAuth
+ case appleOAuth
+ }
+
+
+
+ @Dependency(\.appleManger) var appleLoginManger
+ @Dependency(\.unifiedOAuthUseCase) var unifiedOAuthUseCase
+
+ public var body: some Reducer {
+ BindingReducer()
+ Reduce { state, action in
+ switch action {
+ case .binding(_):
+ return .none
+
+ case .destination(let action):
+ return handleDestinationAction(state: &state, action: action)
+
+ case .view(let viewAction):
+ return handleViewAction(state: &state, action: viewAction)
+
+ case .async(let asyncAction):
+ return handleAsyncAction(state: &state, action: asyncAction)
+
+ case .inner(let innerAction):
+ return handleInnerAction(state: &state, action: innerAction)
+
+ case .delegate(let delegateAction):
+ return handleDelegateAction(state: &state, action: delegateAction)
+ }
+ }
+ .ifLet(\.$destination, action: \.destination)
+ }
+}
+
+extension LoginFeature {
+ private func handleViewAction(
+ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .signInWithSocial(let social):
+ return .send(.async(.login(socialType: social)))
+ }
+ }
+
+
+
+ private func handleDestinationAction(
+ state: inout State,
+ action: PresentationAction
+ ) -> Effect {
+ switch action {
+ case .presented(.termsService(.scope(.close))):
+ // destination 해제 후 온보딩으로 이동
+ return .run { send in
+ // clearDestination 생략하고 바로 온보딩으로 이동
+ try await Task.sleep(for: .seconds(0.3))
+ await send(.delegate(.presentOnBoarding))
+ }
+
+
+ case .presented(.termsService(.delegate(.presentPrivacyWeb))):
+ return .send(.delegate(.presentPrivacyWeb))
+
+
+ default:
+ return .none
+ }
+ }
+
+ private func handleAsyncAction(
+ state: inout State,
+ action: AsyncAction
+ ) -> Effect {
+ switch action {
+ case .prepareAppleRequest(let request):
+ let nonce = appleLoginManger.prepare(request)
+ state.nonce = nonce
+ return .none
+
+ case .appleLogin(let result, let nonce):
+ state.currentSocialType = .apple
+ return .run { send in
+ guard
+ case .success(let auth) = result,
+ let credential = auth.credential as? ASAuthorizationAppleIDCredential,
+ !nonce.isEmpty
+ else {
+ await send(.inner(.loginResponse(.failure(.invalidCredential("Apple 인증 정보가 없습니다")))))
+ return
+ }
+
+ // Apple credential을 직접 처리하여 로그인 완료
+ let outcome = await unifiedOAuthUseCase.processOAuthFlow(
+ with: .apple,
+ appleCredential: credential,
+ nonce: nonce,
+ googleToken: nil
+ )
+ await send(.inner(.loginResponse(outcome)))
+ }
+ .cancellable(id: CancelID.appleOAuth)
+
+ case .login(let socialType):
+ state.currentSocialType = socialType
+ state.$userSession.withLock { $0.provider = socialType }
+ return .run { [
+ appleCredential = state.appleLoginFullName,
+ nonce = state.nonce
+ ] send in
+ let outcome = await unifiedOAuthUseCase.processOAuthFlow(
+ with: socialType,
+ appleCredential: appleCredential,
+ nonce: nonce,
+ googleToken: ""
+ )
+ return await send(.inner(.loginResponse(outcome)))
+ }
+ .cancellable(id: socialType == .apple ? CancelID.appleOAuth : CancelID.googleOAuth)
+ }
+ }
+
+ private func handleDelegateAction(
+ state: inout State,
+ action: DelegateAction
+ ) -> Effect {
+ switch action {
+ case .presentTermsAgreement:
+ state.destination = .termsService(.init())
+ return .none
+
+ case .presentPrivacyWeb:
+ state.destination = nil
+ return .none
+
+ case .presentOnBoarding:
+ return .none
+
+ case .presentMain:
+ return .none
+
+ }
+ }
+
+ private func handleInnerAction(
+ state: inout State,
+ action: InnerAction
+ ) -> Effect {
+ switch action {
+ case .clearDestination:
+ state.destination = nil
+ return .none
+
+ case .loginResponse(let result):
+ switch result {
+ case .success(let loginEntity):
+ state.loginEntity = loginEntity
+
+ if loginEntity.isNewUser {
+ return .send(.delegate(.presentTermsAgreement))
+ } else {
+ return .send(.delegate(.presentMain))
+ }
+
+
+ case .failure(let error):
+ #logNetwork("로그인 실패", error.localizedDescription)
+ let socialType = state.currentSocialType
+ return .run { send in
+ await MainActor.run {
+ let errorMessage: String
+ switch socialType {
+ case .apple:
+ errorMessage = "Apple 인증에 실패하였습니다."
+ case .google:
+ errorMessage = "구글 인증에 실패하였습니다."
+ default:
+ errorMessage = "인증에 실패했어요. 다시 시도해주세요."
+ }
+ ToastManager.shared.showError(errorMessage)
+ }
+ }
+ }
+ }
+ }
+}
+
+
+
+// MARK: - State Equatable & Hashable
+extension LoginFeature.State {
+ public static func == (lhs: LoginFeature.State, rhs: LoginFeature.State) -> Bool {
+ lhs.nonce == rhs.nonce &&
+ lhs.appleAccessToken == rhs.appleAccessToken &&
+ lhs.loginEntity == rhs.loginEntity &&
+ lhs.currentSocialType == rhs.currentSocialType &&
+ lhs.destination == rhs.destination
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(nonce)
+ hasher.combine(appleAccessToken)
+ hasher.combine(currentSocialType)
+ }
+}
+
+// MARK: - Destination State Equatable & Hashable
+extension LoginFeature.Destination.State: Equatable {}
+extension LoginFeature.Destination.State: Hashable {}
diff --git a/Projects/Presentation/Auth/Sources/TermsAgreement/Components/TermsRowView.swift b/Projects/Presentation/Auth/Sources/TermsAgreement/Components/TermsRowView.swift
new file mode 100644
index 0000000..b19e59c
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/TermsAgreement/Components/TermsRowView.swift
@@ -0,0 +1,58 @@
+//
+// TermsRowView.swif
+
+// Auth
+//
+// Created by Wonji Suh on 3/20/26.
+//
+
+import Foundation
+import SwiftUI
+
+import DesignSystem
+
+public struct TermsRowView: View {
+ private let title: String
+ private let isOn: Bool
+ private let action: () -> Void
+ private let onArrowTap: () -> Void
+
+ public init(
+ title: String,
+ isOn: Bool,
+ action: @escaping () -> Void,
+ onArrowTap: @escaping () -> Void,
+ ) {
+ self.title = title
+ self.isOn = isOn
+ self.action = action
+ self.onArrowTap = onArrowTap
+ }
+
+ public var body: some View {
+ HStack(spacing: .zero) {
+ Button(action: action){
+ HStack(spacing: 12) {
+ Image(asset: isOn ? .check: .noCheck)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ .onTapGesture(perform: action)
+
+ Text(title)
+ .pretendardCustomFont(textStyle: .titleRegular)
+ .foregroundStyle(.gray900)
+ }
+ }
+
+ Spacer()
+
+ Button(action: onArrowTap){
+ Image(asset: .arrowRight)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 18, height: 18)
+ }
+ }
+ }
+}
diff --git a/Projects/Presentation/Auth/Sources/TermsAgreement/Reducer/TermsAgreementFeature.swift b/Projects/Presentation/Auth/Sources/TermsAgreement/Reducer/TermsAgreementFeature.swift
new file mode 100644
index 0000000..bc7d2d7
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/TermsAgreement/Reducer/TermsAgreementFeature.swift
@@ -0,0 +1,104 @@
+//
+// TermsAgreementFeature.swift
+// Auth
+//
+// Created by Wonji Suh on 3/19/26.
+//
+
+import Foundation
+import ComposableArchitecture
+import SwiftUI
+
+
+@Reducer
+public struct TermsAgreementFeature {
+ public init() {}
+
+ @ObservableState
+ public struct State: Equatable, Hashable {
+ var privacyAgreed: Bool = false
+ public init() {}
+ }
+
+ public enum Action: ViewAction, BindableAction {
+ case binding(BindingAction)
+ case view(View)
+ case scope(ScopeAction)
+ case delegate(DelegateAction)
+
+ }
+
+ //MARK: - ViewAction
+ @CasePathable
+ public enum View {
+ case privacyAgreementTapped
+ }
+
+ @CasePathable
+ public enum ScopeAction: Equatable {
+ case close
+ }
+
+ @CasePathable
+ public enum DelegateAction: Equatable {
+ case presentPrivacyWeb
+ }
+
+
+ @Dependency(\.continuousClock) var clock
+
+ public var body: some Reducer {
+ BindingReducer()
+ Reduce { state, action in
+ switch action {
+ case .binding(_):
+ return .none
+
+ case .view(let viewAction):
+ return handleViewAction(state: &state, action: viewAction)
+
+ case .scope(let scopeAction):
+ return handleScopeAction(state: &state, action: scopeAction)
+
+ case .delegate(let navigationAction):
+ return handleNavigationAction(state: &state, action: navigationAction)
+ }
+ }
+ }
+}
+
+extension TermsAgreementFeature {
+ private func handleViewAction(
+ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .privacyAgreementTapped:
+ state.privacyAgreed.toggle()
+ return .none
+ }
+ }
+
+ private func handleScopeAction(
+ state: inout State,
+ action: ScopeAction
+ ) -> Effect {
+ switch action {
+ case .close:
+ // 약관 동의 완료 - 바로 종료 신호를 보냄
+ return .none
+ }
+ }
+
+ private func handleNavigationAction(
+ state: inout State,
+ action: DelegateAction
+ ) -> Effect {
+ switch action {
+ case .presentPrivacyWeb:
+ return .none
+ }
+ }
+}
+
+
diff --git a/Projects/Presentation/Auth/Sources/TermsAgreement/View/TermsAgreementView.swift b/Projects/Presentation/Auth/Sources/TermsAgreement/View/TermsAgreementView.swift
new file mode 100644
index 0000000..5e3592d
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/TermsAgreement/View/TermsAgreementView.swift
@@ -0,0 +1,119 @@
+//
+// TermsAgreementView.swift
+// Auth
+//
+// Created by Wonji Suh on 3/19/26.
+//
+
+import SwiftUI
+
+import DesignSystem
+import ComposableArchitecture
+
+public struct TermsAgreementView: View {
+ @Bindable var store: StoreOf
+ @Environment(\.modalDismiss) private var modalDismiss
+
+ public init(
+ store: StoreOf
+ ) {
+ self.store = store
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Spacer()
+ .frame(height: 32) // 상단 간격 조정
+
+ termsAgreementTitle()
+
+ Spacer()
+ .frame(height: 16) // 제목과 컨텐츠 사이 간격
+
+ termsAgreementContent()
+
+ Spacer() // 유연한 공간
+
+ termsToggleButton()
+
+ Spacer()
+ .frame(height: 24) // 토글과 버튼 사이 간격
+
+ confirmButton()
+
+ Spacer()
+ .frame(height: 16) // 하단 간격
+ }
+ .padding(.horizontal, 24)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .background(.white) // 배경색을 흰색으로
+ }
+}
+
+
+extension TermsAgreementView {
+
+ @ViewBuilder
+ fileprivate func termsAgreementTitle() -> some View {
+ VStack {
+ HStack {
+ Text("개인정보처리 방침에")
+ .pretendardCustomFont(textStyle: .heading2)
+ .foregroundStyle(.gray900)
+
+ Spacer()
+ }
+ HStack {
+ Text("동의하시겠습니까?")
+ .pretendardCustomFont(textStyle: .heading2)
+ .foregroundStyle(.gray900)
+
+ Spacer()
+ }
+ }
+ }
+
+ @ViewBuilder
+ fileprivate func termsAgreementContent() -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("회원 가입 및 서비스 제공을 위해 개인정보를 ")
+ .pretendardCustomFont(textStyle: .bodyRegular)
+ .foregroundStyle(.gray800)
+
+ Text("수집·이용합니다.")
+ .pretendardCustomFont(textStyle: .bodyRegular)
+ .foregroundStyle(.gray800)
+ }
+ }
+
+ @ViewBuilder
+ fileprivate func termsToggleButton() -> some View {
+ TermsRowView(
+ title: "개인정보처리 방침 동의",
+ isOn: store.privacyAgreed,
+ action: {
+ store.send(.view(.privacyAgreementTapped))
+ },
+ onArrowTap: {
+ store.send(.delegate(.presentPrivacyWeb))
+ }
+ )
+ }
+
+ @ViewBuilder
+ fileprivate func confirmButton() -> some View {
+ CustomButton(
+ action: {
+ modalDismiss()
+
+ // 모달이 닫힌 후 약간의 지연을 두고 close 액션 전송
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ store.send(.scope(.close))
+ }
+ },
+ title: "확인",
+ config: CustomButtonConfig.create(),
+ isEnable: store.privacyAgreed
+ )
+ }
+}
diff --git a/Projects/Presentation/Auth/Sources/View/LoginView.swift b/Projects/Presentation/Auth/Sources/View/LoginView.swift
new file mode 100644
index 0000000..04c517d
--- /dev/null
+++ b/Projects/Presentation/Auth/Sources/View/LoginView.swift
@@ -0,0 +1,96 @@
+//
+// LoginView.swift
+// Auth
+//
+// Created by Wonji Suh on 3/17/26.
+//
+
+import SwiftUI
+
+import DesignSystem
+import Entity
+
+import ComposableArchitecture
+
+public struct LoginView: View {
+ @Bindable var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ ZStack {
+ Color.gray100
+ .edgesIgnoringSafeArea(.all)
+
+
+ VStack {
+ loginLogo()
+
+ socialLoginButtons()
+
+ guestLookAroundText()
+ }
+ .presentDSModal(
+ item: $store.scope(state: \.destination?.termsService, action: \.destination.termsService),
+ height: .fraction(0.4),
+ showDragIndicator: true
+ ) { termServiceStore in
+ TermsAgreementView(store: termServiceStore)
+ }
+ }
+ }
+}
+
+
+extension LoginView {
+
+ @ViewBuilder
+ private func loginLogo() -> some View {
+ VStack{
+ Spacer()
+
+ Text("Time Spot")
+ .pretendardFont(family: .SemiBold, size: 48)
+ .foregroundStyle(.black)
+
+ Spacer()
+ }
+ }
+
+ @ViewBuilder
+ private func socialLoginButtons() -> some View {
+ VStack(alignment: .center, spacing: 8) {
+ ForEach(SocialType.allCases) { type in
+ SocialLoginButton(store: store, type: type) {
+ // 애플 로그인은 SignInWithAppleButton 자체 처리 사용
+ // 구글 로그인만 여기서 처리
+ store.send(.view(.signInWithSocial(social: type)))
+ }
+ }
+ }
+ .padding(.horizontal, 22)
+ }
+
+ @ViewBuilder
+ private func guestLookAroundText() -> some View {
+ VStack(alignment: .center) {
+ Spacer()
+ .frame(height: 16)
+
+ Text("비회원으로 시작하기")
+ .pretendardCustomFont(textStyle: .caption)
+ .foregroundStyle(.gray800)
+ .underline(true, color: .gray800.opacity(0.5))
+ .onTapGesture {
+ store.send(.delegate(.presentPrivacyWeb))
+ }
+
+
+ Spacer()
+ .frame(height: 70)
+ }
+ }
+
+}
diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift
index ca149f9..2b4cc12 100644
--- a/Projects/Presentation/Home/Project.swift
+++ b/Projects/Presentation/Home/Project.swift
@@ -5,7 +5,7 @@ import ProjectTemplatePlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Home",
bundleId: .appBundleID(name: ".Home"),
product: .staticFramework,
diff --git a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/LocationPermissionManager.swift
index b93c552..d6cdb69 100644
--- a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift
+++ b/Projects/Presentation/Home/Sources/LocationPermissionManager.swift
@@ -15,7 +15,10 @@ import UIKit
// Swift Concurrency를 사용한 위치 권한 전용 관리자
@MainActor
-public class LocationPermissionManager: NSObject {
+public class LocationPermissionManager: NSObject, Sendable {
+
+ // 싱글톤 인스턴스
+ public static let shared = LocationPermissionManager()
public var authorizationStatus: CLAuthorizationStatus = .notDetermined
public var currentLocation: CLLocation?
public var locationError: String?
@@ -24,6 +27,10 @@ public class LocationPermissionManager: NSObject {
private var authorizationContinuation: CheckedContinuation?
private var locationContinuation: CheckedContinuation?
+ // 지속적인 위치 업데이트 콜백
+ public var onLocationUpdate: ((CLLocation) -> Void)?
+ public var onLocationError: ((Error) -> Void)?
+
public override init() {
super.init()
setupLocationManager()
@@ -43,10 +50,8 @@ public class LocationPermissionManager: NSObject {
return .denied
}
- let currentStatus = locationManager.authorizationStatus
- self.authorizationStatus = currentStatus
-
- switch currentStatus {
+ // authorizationStatus는 델리게이트에서 업데이트된 값 사용
+ switch authorizationStatus {
case .notDetermined:
return await withCheckedContinuation { continuation in
self.authorizationContinuation = continuation
@@ -54,12 +59,12 @@ public class LocationPermissionManager: NSObject {
}
case .denied, .restricted:
locationError = "위치 권한이 거부되었습니다. 설정에서 허용해 주세요."
- return currentStatus
+ return authorizationStatus
case .authorizedWhenInUse, .authorizedAlways:
- return currentStatus
+ return authorizationStatus
@unknown default:
locationError = "알 수 없는 위치 권한 상태입니다."
- return currentStatus
+ return authorizationStatus
}
}
@@ -158,7 +163,10 @@ extension LocationPermissionManager: CLLocationManagerDelegate {
self.currentLocation = location
self.locationError = nil
- // continuation이 있으면 결과 반환
+ // 지속적인 위치 업데이트 콜백 호출
+ self.onLocationUpdate?(location)
+
+ // continuation이 있으면 결과 반환 (일회성 요청용)
if let continuation = self.locationContinuation {
self.locationContinuation = nil
continuation.resume(returning: location)
@@ -170,7 +178,10 @@ extension LocationPermissionManager: CLLocationManagerDelegate {
Task { @MainActor in
self.locationError = "위치 업데이트 실패: \(error.localizedDescription)"
- // continuation이 있으면 에러 반환
+ // 지속적인 위치 업데이트 에러 콜백 호출
+ self.onLocationError?(error)
+
+ // continuation이 있으면 에러 반환 (일회성 요청용)
if let continuation = self.locationContinuation {
self.locationContinuation = nil
continuation.resume(throwing: error)
diff --git a/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift b/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift
index 618ae86..bd779f3 100644
--- a/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift
+++ b/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift
@@ -7,20 +7,20 @@
import Foundation
import NMapsMap
+import LogMacro
-public final class NaverMapInitializer {
- public static let shared = NaverMapInitializer()
+// MARK: - 네이버 지도 SDK 초기화 (Namespace)
+public enum NaverMapInitializer {
- private init() {}
-
- public func initialize() {
-
- // 네이버 공식 iOS 지도 SDK 초기화
- let clientId = Bundle.main.object(forInfoDictionaryKey: "NMFGovClientId") as? String ?? ""
- NMFAuthManager.shared().ncpKeyId = clientId
-
-
- print("✅ [네이버맵] 공식 SDK 초기화 완료")
+ /// 네이버 지도 SDK 초기화 (앱 시작 시 한 번만 호출)
+ public static func initialize() {
+ let clientId = Bundle.main.object(forInfoDictionaryKey: "NMFGovClientId") as? String ?? ""
+ guard !clientId.isEmpty else {
+ fatalError("🚨 [네이버맵] NMFGovClientId가 설정되지 않았습니다")
}
+
+ NMFAuthManager.shared().ncpKeyId = clientId
+ #logDebug("✅ [네이버맵] 공식 SDK 초기화 완료 (ClientID: \(clientId.prefix(8))...)")
+ }
}
diff --git a/Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift b/Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift
index 7b3e1ec..239eece 100644
--- a/Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift
+++ b/Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift
@@ -135,46 +135,11 @@ extension HomeReducer {
) -> Effect {
switch action {
case .onAppear:
- // 앱이 나타날 때 위치 권한 상태 확인
- let currentStatus = CLLocationManager().authorizationStatus
- state.locationPermissionStatus = currentStatus
-
- switch currentStatus {
- case .notDetermined:
- // 권한 미결정 시 바로 팝업 표시
- state.alert = AlertState {
- TextState("위치 권한이 필요합니다")
- } actions: {
- ButtonState(action: Alert.confirmLocationPermission) {
- TextState("허용")
- }
- ButtonState(role: .cancel, action: Alert.cancelLocationPermission) {
- TextState("취소")
- }
- } message: {
- TextState("TimeSpot이 근처 장소를 찾고 지도에 현재 위치를 표시하기 위해 위치 정보가 필요합니다.")
- }
- return .none
- case .authorizedWhenInUse, .authorizedAlways:
- return .send(.async(.startLocationUpdates))
- case .denied, .restricted:
- state.isLocationPermissionDenied = true
- // 권한 거부 시 안내 팝업 표시
- state.alert = AlertState {
- TextState("위치 권한이 거부되었습니다")
- } actions: {
- ButtonState(action: Alert.openSettings) {
- TextState("설정으로 이동")
- }
- ButtonState(role: .cancel, action: Alert.dismissAlert) {
- TextState("나중에")
- }
- } message: {
- TextState("위치 기반 서비스를 사용하려면 설정에서 위치 권한을 허용해주세요.")
- }
- return .none
- @unknown default:
- return .none
+ // 앱이 나타날 때 위치 권한 상태 확인 - UI 블로킹 방지
+ return .run { send in
+ let locationManager = await LocationPermissionManager.shared
+ let currentStatus = await locationManager.authorizationStatus
+ await send(.inner(.locationPermissionStatusChanged(currentStatus)))
}
case .onDisappear:
@@ -318,13 +283,15 @@ extension HomeReducer {
switch action {
case .requestLocationPermission:
return .run { send in
- let locationManager = await LocationPermissionManager()
+ let locationManager = await LocationPermissionManager.shared
let status = await locationManager.requestLocationPermission()
await send(.inner(.locationPermissionStatusChanged(status)))
- // 권한이 허용되면 현재 위치 가져오기
+ // 권한이 허용되면 현재 위치 가져오기 시작
if status == .authorizedWhenInUse || status == .authorizedAlways {
+ await locationManager.startLocationUpdates()
+
do {
if let location = try await locationManager.requestCurrentLocation() {
await send(.inner(.locationUpdated(location)))
@@ -342,20 +309,38 @@ extension HomeReducer {
case .requestFullAccuracy:
return .run { send in
await MainActor.run {
- let locationManager = LocationPermissionManager()
+ let locationManager = LocationPermissionManager.shared
locationManager.requestFullAccuracy()
Task {
try await Task.sleep(for: .seconds(1))
- send(.async(.startLocationUpdates))
+ await send(.async(.startLocationUpdates))
}
}
}
case .startLocationUpdates:
return .run { send in
- let locationManager = await LocationPermissionManager()
+ let locationManager = await LocationPermissionManager.shared
+ // 지속적인 위치 업데이트 콜백 설정 (MainActor에서 실행)
+ await MainActor.run {
+ locationManager.onLocationUpdate = { location in
+ Task { @MainActor in
+ await send(.inner(.locationUpdated(location)))
+ }
+ }
+
+ locationManager.onLocationError = { error in
+ Task { @MainActor in
+ await send(.inner(.locationUpdateFailed(error.localizedDescription)))
+ }
+ }
+ }
+
+ await locationManager.startLocationUpdates()
+
+ // 초기 위치도 가져오기
do {
if let location = try await locationManager.requestCurrentLocation() {
await send(.inner(.locationUpdated(location)))
@@ -363,16 +348,12 @@ extension HomeReducer {
} catch {
await send(.inner(.locationUpdateFailed(error.localizedDescription)))
}
-
- if let error = await locationManager.locationError {
- await send(.inner(.locationUpdateFailed(error)))
- }
}
case .stopLocationUpdates:
return .run { send in
await MainActor.run {
- let locationManager = LocationPermissionManager()
+ let locationManager = LocationPermissionManager.shared
locationManager.stopLocationUpdates()
}
}
@@ -387,7 +368,7 @@ extension HomeReducer {
try await getRouteUseCase.execute(
from: from,
to: destination.coordinate,
- option: .walking
+ option: .traoptimal // 최적 경로로 변경
)
}
.mapError(DirectionError.from)
diff --git a/Projects/Presentation/OnBoarding/OnBoardingTests/Sources/Test.swift b/Projects/Presentation/OnBoarding/OnBoardingTests/Sources/Test.swift
new file mode 100644
index 0000000..2713ea5
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/OnBoardingTests/Sources/Test.swift
@@ -0,0 +1,8 @@
+//
+// base.swift
+// DDDAttendance
+//
+// Created by Roy on 2026-03-21
+// Copyright © 2026 DDD , Ltd. All rights reserved.
+//
+
diff --git a/Projects/Presentation/OnBoarding/Project.swift b/Projects/Presentation/OnBoarding/Project.swift
new file mode 100644
index 0000000..8d1bc30
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Project.swift
@@ -0,0 +1,21 @@
+import Foundation
+import ProjectDescription
+import DependencyPlugin
+import ProjectTemplatePlugin
+import ProjectTemplatePlugin
+import DependencyPackagePlugin
+
+let project = Project.makeModule(
+ name: "OnBoarding",
+ bundleId: .appBundleID(name: ".OnBoarding"),
+ product: .staticFramework,
+ settings: .settings(),
+ dependencies: [
+ .Domain(implements: .UseCase),
+ .Shared(implements: .DesignSystem),
+ .SPM.composableArchitecture,
+ .SPM.tcaCoordinator,
+ ],
+ sources: ["Sources/**"],
+ hasTests: true
+)
diff --git a/Projects/Presentation/OnBoarding/Sources/Components/SelectExternalMap.swift b/Projects/Presentation/OnBoarding/Sources/Components/SelectExternalMap.swift
new file mode 100644
index 0000000..1f2f97c
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Sources/Components/SelectExternalMap.swift
@@ -0,0 +1,55 @@
+//
+// SelectExternalMap.swift
+// OnBoarding
+//
+// Created by Wonji Suh on 3/22/26.
+//
+
+import SwiftUI
+import DesignSystem
+
+public struct SelectExternalMap: View {
+ private let title: String
+ private let imageName: String
+ private var isSelected: Bool = false
+
+
+ public init(
+ title: String,
+ imageName: String,
+ isSelected: Bool
+ ) {
+ self.title = title
+ self.imageName = imageName
+ self.isSelected = isSelected
+ }
+
+ public var body: some View {
+ HStack {
+ Image(assetName: imageName)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 48, height: 48)
+
+ Spacer()
+ .frame(width: 20)
+
+ Text(title)
+ .pretendardCustomFont(textStyle: .titleRegular)
+ .foregroundColor(.gray900)
+
+ Spacer()
+
+ }
+ .padding(15)
+ .background {
+ RoundedRectangle(cornerRadius: 28)
+ .fill(.clear)
+ .stroke(isSelected ? .orange500 : .gray400, style: .init(lineWidth: 1))
+ .background(isSelected ? .orange200 : .white)
+ .cornerRadius(28)
+ }
+
+ }
+}
+
diff --git a/Projects/Presentation/OnBoarding/Sources/Components/StepNavigationBar.swift b/Projects/Presentation/OnBoarding/Sources/Components/StepNavigationBar.swift
new file mode 100644
index 0000000..53462b5
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Sources/Components/StepNavigationBar.swift
@@ -0,0 +1,30 @@
+//
+// StepNavigationBar.swift
+// OnBoarding
+//
+// Created by Wonji Suh on 3/21/26.
+//
+
+import SwiftUI
+import DesignSystem
+
+
+public struct StepNavigationBar: View {
+ private let activeStep: Int
+ private let totalSteps: Int
+
+ public init(activeStep: Int, totalSteps: Int = 4) {
+ self.activeStep = activeStep
+ self.totalSteps = totalSteps
+ }
+
+ public var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ ForEach(1...totalSteps, id: \.self) { step in
+ Capsule()
+ .fill(step <= activeStep ? Color.orange800 : Color.enableColor)
+ .frame(maxWidth: 48, minHeight: 4, maxHeight: 4)
+ }
+ }
+ }
+}
diff --git a/Projects/Presentation/OnBoarding/Sources/Coordintaor/Reducer/OnBoardingCoordinator.swift b/Projects/Presentation/OnBoarding/Sources/Coordintaor/Reducer/OnBoardingCoordinator.swift
new file mode 100644
index 0000000..59aa5c2
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Sources/Coordintaor/Reducer/OnBoardingCoordinator.swift
@@ -0,0 +1,144 @@
+//
+// OnBoardingCoordinator.swift
+// OnBoarding
+//
+// Created by Wonji Suh on 3/21/26.
+//
+
+import ComposableArchitecture
+import TCACoordinators
+
+@Reducer
+public struct OnBoardingCoordinator {
+
+ public init(){}
+
+ @ObservableState
+ public struct State: Equatable, Hashable {
+ var routes: [Route]
+
+ public init() {
+ self.routes = [.root(.onBoarding(.init()), withNavigation: true)]
+ }
+ }
+
+ public enum Action {
+ case router(IndexedRouterActionOf)
+ case view(View)
+ case async(AsyncAction)
+ case inner(InnerAction)
+ case navigation(NavigationAction)
+ }
+
+ // MARK: - ViewAction
+ @CasePathable
+ public enum View {
+ case backAction
+ case backToRootAction
+ }
+
+ // MARK: - AsyncAction 비동기 처리 액션
+
+ public enum AsyncAction: Equatable {
+
+ }
+
+ // MARK: - 앱내에서 사용하는 액션
+ public enum InnerAction: Equatable {
+
+ }
+
+ // MARK: - NavigationAction
+ public enum NavigationAction: Equatable {
+ case presentMain
+ }
+
+ public var body: some Reducer {
+ Reduce { state, action in
+ switch action {
+ case .router(let routeAction):
+ return routerAction(state: &state, action: routeAction)
+
+ case .view(let viewAction):
+ return handleViewAction(state: &state, action: viewAction)
+
+ case .async(let asyncAction):
+ return handleAsyncAction(state: &state, action: asyncAction)
+
+ case .inner(let innerAction):
+ return handleInnerAction(state: &state, action: innerAction)
+
+ case .navigation(let navigationAction):
+ return handleNavigationAction(state: &state, action: navigationAction)
+ }
+ }
+ .forEachRoute(\.routes, action: \.router)
+ }
+
+}
+
+extension OnBoardingCoordinator {
+ private func routerAction(
+ state: inout State,
+ action: IndexedRouterActionOf
+ ) -> Effect {
+ switch action {
+
+ case .routeAction(id: _, action: .onBoarding(.navigation(.onBoardingCompleted))):
+ return .send(.navigation(.presentMain))
+
+ default:
+ return .none
+ }
+ }
+
+ private func handleViewAction(
+ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .backAction:
+ state.routes.goBack()
+ return .none
+
+ case .backToRootAction:
+ state.routes.goBackToRoot()
+ return .none
+ }
+ }
+
+ private func handleNavigationAction(
+ state: inout State,
+ action: NavigationAction
+ ) -> Effect {
+ switch action {
+ case .presentMain:
+ return .none
+ }
+ }
+
+ private func handleAsyncAction(
+ state: inout State,
+ action: AsyncAction
+ ) -> Effect {
+
+ }
+
+ private func handleInnerAction(
+ state: inout State,
+ action: InnerAction
+ ) -> Effect {
+
+ }
+}
+
+extension OnBoardingCoordinator {
+ @Reducer
+ public enum OnBoardingScreen {
+ case onBoarding(OnBoardingFeature)
+ }
+}
+
+// MARK: - OnBoardingScreen State Equatable & Hashable
+extension OnBoardingCoordinator.OnBoardingScreen.State: Equatable {}
+extension OnBoardingCoordinator.OnBoardingScreen.State: Hashable {}
diff --git a/Projects/Presentation/OnBoarding/Sources/Coordintaor/View/OnBoardingCoordinatorView.swift b/Projects/Presentation/OnBoarding/Sources/Coordintaor/View/OnBoardingCoordinatorView.swift
new file mode 100644
index 0000000..51866c2
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Sources/Coordintaor/View/OnBoardingCoordinatorView.swift
@@ -0,0 +1,29 @@
+//
+// OnBoardingCoordinatorView.swift
+// OnBoarding
+//
+// Created by Wonji Suh on 3/21/26.
+//
+
+import SwiftUI
+
+import ComposableArchitecture
+import TCACoordinators
+
+public struct OnBoardingCoordinatorView: View {
+ @Bindable var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ TCARouter(store.scope(state: \.routes, action: \.router)) { screens in
+ switch screens.case {
+ case .onBoarding(let onBoardingStore):
+ OnBoardingView(store: onBoardingStore)
+ .navigationBarBackButtonHidden()
+ }
+ }
+ }
+}
diff --git a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift
new file mode 100644
index 0000000..94834e5
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift
@@ -0,0 +1,184 @@
+//
+// OnBoardingFeature.swift
+// OnBoarding
+//
+// Created by Wonji Suh on 3/21/26.
+//
+
+import Foundation
+import ComposableArchitecture
+
+import UseCase
+import Entity
+import LogMacro
+
+@Reducer
+public struct OnBoardingFeature {
+ public init() {}
+
+ private enum SharedKeys {
+ static let userSession = "UserSession"
+ }
+
+ @ObservableState
+ public struct State: Hashable {
+
+ public init() {}
+ var stepRange: ClosedRange = 1...4
+ var activeStep: Int = 1
+ var selectedMap: ExternalMapType? = nil
+ var loginEntity: LoginEntity? = nil
+ @Shared(.inMemory(SharedKeys.userSession)) var userSession: UserSession = .empty
+ }
+
+ public enum Action: ViewAction, BindableAction {
+ case binding(BindingAction)
+ case view(View)
+ case async(AsyncAction)
+ case inner(InnerAction)
+ case navigation(NavigationAction)
+
+ }
+
+ //MARK: - ViewAction
+ @CasePathable
+ public enum View {
+ case nextStepButtonTapped
+ case mapSelected(ExternalMapType)
+ }
+
+
+
+ //MARK: - AsyncAction 비동기 처리 액션
+ public enum AsyncAction: Equatable {
+ case signup
+ }
+
+ //MARK: - 앱내에서 사용하는 액션
+ public enum InnerAction: Equatable {
+ case signUpResponse(Result)
+ }
+
+ //MARK: - NavigationAction
+ public enum NavigationAction: Equatable {
+ case onBoardingCompleted
+ }
+
+ nonisolated enum CancelID: Hashable {
+ case signup
+ }
+
+ @Dependency(\.signUpUseCase) var signUpUseCase
+
+ public var body: some Reducer {
+ BindingReducer()
+ Reduce { state, action in
+ switch action {
+ case .binding(_):
+ return .none
+
+ case .view(let viewAction):
+ return handleViewAction(state: &state, action: viewAction)
+
+ case .async(let asyncAction):
+ return handleAsyncAction(state: &state, action: asyncAction)
+
+ case .inner(let innerAction):
+ return handleInnerAction(state: &state, action: innerAction)
+
+ case .navigation(let navigationAction):
+ return handleNavigationAction(state: &state, action: navigationAction)
+ }
+ }
+ }
+}
+
+extension OnBoardingFeature {
+ private func handleViewAction(
+ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .nextStepButtonTapped:
+ if state.activeStep >= state.stepRange.upperBound {
+ return .send(.async(.signup))
+ }
+ state.activeStep += 1
+ return .none
+
+ case .mapSelected(let mapType):
+ state.selectedMap = state.selectedMap == mapType ? nil : mapType
+ state.$userSession.withLock {
+ $0.mapType = mapType
+ }
+ return .none
+ }
+ }
+
+ private func handleAsyncAction(
+ state: inout State,
+ action: AsyncAction
+ ) -> Effect {
+ switch action {
+ case .signup:
+ return .run { [
+ userSession = state.userSession
+ ] send in
+ let signupResult = await Result {
+ try await signUpUseCase.registerUser(userSession: userSession)
+ }
+ .mapError(SignUpError.from)
+ return await send(.inner(.signUpResponse(signupResult)))
+ }
+ .cancellable(id: CancelID.signup, cancelInFlight: true)
+ }
+ }
+
+ private func handleNavigationAction(
+ state: inout State,
+ action: NavigationAction
+ ) -> Effect {
+ switch action {
+ case .onBoardingCompleted:
+ // Coordinator에서 처리
+ return .none
+ }
+ }
+
+ private func handleInnerAction(
+ state: inout State,
+ action: InnerAction
+ ) -> Effect {
+ switch action {
+
+ case .signUpResponse(let result):
+ switch result {
+ case .success(let data):
+ state.loginEntity = data
+ return .send(.navigation(.onBoardingCompleted))
+
+ case .failure(let error):
+ #logDebug("회원가입 실패", error.localizedDescription)
+ return .none
+ }
+ }
+ }
+}
+
+// MARK: - State Equatable & Hashable
+extension OnBoardingFeature.State: Equatable {
+ public static func == (lhs: OnBoardingFeature.State, rhs: OnBoardingFeature.State) -> Bool {
+ lhs.stepRange == rhs.stepRange &&
+ lhs.activeStep == rhs.activeStep &&
+ lhs.selectedMap == rhs.selectedMap &&
+ lhs.loginEntity == rhs.loginEntity
+ }
+}
+extension OnBoardingFeature.State {
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(activeStep)
+ hasher.combine(selectedMap)
+ }
+}
+
+
diff --git a/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift b/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift
new file mode 100644
index 0000000..c5a964b
--- /dev/null
+++ b/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift
@@ -0,0 +1,238 @@
+//
+// OnBoardingView.swift
+// OnBoarding
+//
+// Created by Wonji Suh on 3/21/26.
+//
+
+import SwiftUI
+import ComposableArchitecture
+
+import DesignSystem
+import Entity
+
+public struct OnBoardingView: View {
+ @Bindable var store: StoreOf
+
+ public init(
+ store: StoreOf
+ ) {
+ self.store = store
+ }
+
+ public var body: some View {
+ ZStack {
+ Color.gray100
+
+ VStack {
+ Spacer()
+ .frame(height: 20)
+
+ StepOnBoardingView(activeStep: store.activeStep)
+ .animation(.easeInOut(duration: 0.3), value: store.activeStep)
+ .transition(.opacity)
+
+ Spacer()
+ }
+ }
+ }
+}
+
+
+// MARK: - 공통 레이아웃
+extension OnBoardingView {
+
+ @ViewBuilder
+ private func stepContentView(
+ @ViewBuilder title: () -> some View,
+ subtitle1: String,
+ subtitle2: String,
+ imageAsset: ImageAsset
+ ) -> some View {
+ VStack(alignment: .center) {
+ StepNavigationBar(activeStep: store.activeStep)
+
+ Spacer()
+ .frame(height: 50)
+
+ title()
+
+ Spacer()
+ .frame(height: 12)
+
+ Text(subtitle1)
+ .pretendardCustomFont(textStyle: .bodyRegular)
+ .foregroundStyle(.mediumGray)
+
+ Text(subtitle2)
+ .pretendardCustomFont(textStyle: .bodyRegular)
+ .foregroundStyle(.mediumGray)
+
+ Image(asset: imageAsset)
+ .resizable()
+ .scaledToFit()
+ .frame(height: 393)
+
+ nextStepOnBoardingButton()
+ }
+ }
+
+ @ViewBuilder
+ private func nextStepOnBoardingButton() -> some View {
+ VStack {
+ Spacer()
+
+ CustomButton(
+ action: { store.send(.view(.nextStepButtonTapped)) },
+ title: store.activeStep >= store.stepRange.upperBound ? "시작하기" : "다음으로",
+ config: CustomButtonConfig.create(),
+ isEnable: true
+ )
+
+ Spacer()
+ .frame(height: 32)
+ }
+ .padding(.horizontal, 24)
+ }
+}
+
+// MARK: - 스텝별 콘텐츠
+extension OnBoardingView {
+
+ @ViewBuilder
+ private func StepOnBoardingView(activeStep: Int) -> some View {
+ switch activeStep {
+ case 1:
+ firstStepOnBoardingView()
+ case 2:
+ secondStepOnBoardingView()
+ case 3:
+ thirdStepOnBoardingView()
+ case 4:
+ lastStepOnBoardingView()
+ default:
+ EmptyView()
+ }
+ }
+
+ @ViewBuilder
+ private func firstStepOnBoardingView() -> some View {
+ stepContentView(
+ title: {
+ HStack(spacing: .zero) {
+ Text("열차 출발 전")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray900)
+
+ Text(" 대기 시간,")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.orange800)
+ }
+
+ Text("그냥 보내지 마세요")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray900)
+ },
+ subtitle1: "기차를 기다리는 동안 역 주변의",
+ subtitle2: "다양한 공간을 발견해 보세요.",
+ imageAsset: .onBoardingLogo1
+ )
+ }
+
+ @ViewBuilder
+ private func secondStepOnBoardingView() -> some View {
+ stepContentView(
+ title: {
+ Text("남은 시간에 맞는 장소를")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray800)
+
+ HStack(spacing: .zero) {
+ Text(" 추천")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.orange800)
+ Text("해 드려요")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray900)
+ }
+ },
+ subtitle1: "열차 출발까지 남은 시간을 기준으로",
+ subtitle2: "지금 방문하기 좋은 장소를 추천합니다.",
+ imageAsset: .onBoardingLogo2
+ )
+ }
+
+ @ViewBuilder
+ private func thirdStepOnBoardingView() -> some View {
+ stepContentView(
+ title: {
+ HStack(spacing: .zero) {
+ Text("열차 시간을 ")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray900)
+
+ Text("놓치지 않도록")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.orange800)
+ }
+
+ Text("안내해 드려요")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray900)
+ },
+ subtitle1: "열차 출발 시간을 기준으로 역으로",
+ subtitle2: "돌아와야 하는 시간을 함께 알려드립니다.",
+ imageAsset: .onBoardingLogo3
+ )
+ }
+
+ @ViewBuilder
+ private func lastStepOnBoardingView() -> some View {
+ VStack(alignment: .center) {
+ StepNavigationBar(activeStep: store.activeStep)
+
+ Spacer()
+ .frame(height: 50)
+
+ Text("사용할 지도 앱을 선택해주세요")
+ .pretendardCustomFont(textStyle: .heading1)
+ .foregroundStyle(.gray900)
+
+ Spacer()
+ .frame(height: 12)
+
+ Text("선택한 지도 앱으로 목적지까지")
+ .pretendardCustomFont(textStyle: .bodyRegular)
+ .foregroundStyle(.mediumGray)
+
+ Text("길 안내를 받을 수 있어요.")
+ .pretendardCustomFont(textStyle: .bodyRegular)
+ .foregroundStyle(.mediumGray)
+
+ Spacer()
+ .frame(height: 98)
+
+ mapSelectionList()
+
+ nextStepOnBoardingButton()
+ }
+ }
+
+ @ViewBuilder
+ private func mapSelectionList() -> some View {
+ VStack(spacing: 12) {
+ ForEach(ExternalMapType.allCases, id: \.self) { mapType in
+ let isSelected = store.selectedMap == mapType
+ SelectExternalMap(
+ title: mapType.description,
+ imageName: mapType.image,
+ isSelected: isSelected
+ )
+ .onTapGesture {
+ store.send(.view(.mapSelected(mapType)))
+ }
+ }
+ }
+ .padding(.horizontal, 24)
+ }
+}
diff --git a/Projects/Presentation/Presentation/Project.swift b/Projects/Presentation/Presentation/Project.swift
index 37cb147..b4a3996 100644
--- a/Projects/Presentation/Presentation/Project.swift
+++ b/Projects/Presentation/Presentation/Project.swift
@@ -4,14 +4,16 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Presentation",
bundleId: .appBundleID(name: ".Presentation"),
product: .staticFramework,
settings: .settings(),
dependencies: [
.Presentation(implements: .Splash),
- .Presentation(implements: .Home)
+ .Presentation(implements: .Home),
+ .Presentation(implements: .Auth),
+ .Presentation(implements: .Profile)
],
sources: ["Sources/**"],
hasTests: false
diff --git a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift
index c4359ba..4df6b70 100644
--- a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift
+++ b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift
@@ -10,3 +10,5 @@
@_exported import Splash
@_exported import Home
+@_exported import Auth
+@_exported import Profile
diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift
new file mode 100644
index 0000000..d0e049e
--- /dev/null
+++ b/Projects/Presentation/Profile/Project.swift
@@ -0,0 +1,21 @@
+import Foundation
+import ProjectDescription
+import DependencyPlugin
+import ProjectTemplatePlugin
+import ProjectTemplatePlugin
+import DependencyPackagePlugin
+
+let project = Project.makeAppModule(
+ name: "Profile",
+ bundleId: .appBundleID(name: ".Profile"),
+ product: .staticFramework,
+ settings: .settings(),
+ dependencies: [
+
+ .Domain(implements: .UseCase),
+ .Shared(implements: .DesignSystem),
+ .SPM.composableArchitecture,
+ .SPM.tcaCoordinator,
+ ],
+ sources: ["Sources/**"]
+)
diff --git a/Projects/Presentation/Profile/Sources/Base.swift b/Projects/Presentation/Profile/Sources/Base.swift
new file mode 100644
index 0000000..ded44bb
--- /dev/null
+++ b/Projects/Presentation/Profile/Sources/Base.swift
@@ -0,0 +1,22 @@
+//
+// base.swift
+// DDDAttendance.
+//
+// Created by Roy on 2026-03-23
+// 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/Presentation/Profile/rofileTests/Sources/Test.swift b/Projects/Presentation/Profile/rofileTests/Sources/Test.swift
new file mode 100644
index 0000000..be4748d
--- /dev/null
+++ b/Projects/Presentation/Profile/rofileTests/Sources/Test.swift
@@ -0,0 +1,8 @@
+//
+// base.swift
+// DDDAttendance
+//
+// Created by Roy on 2026-03-23
+// Copyright © 2026 DDD , Ltd. All rights reserved.
+//
+
diff --git a/Projects/Presentation/Splash/Project.swift b/Projects/Presentation/Splash/Project.swift
index f704afc..99fff43 100644
--- a/Projects/Presentation/Splash/Project.swift
+++ b/Projects/Presentation/Splash/Project.swift
@@ -5,7 +5,7 @@ import ProjectTemplatePlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Splash",
bundleId: .appBundleID(name: ".Splash"),
product: .staticFramework,
diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift
index 144da28..39a4ab8 100644
--- a/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift
+++ b/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift
@@ -8,14 +8,20 @@
import Foundation
import ComposableArchitecture
+import UseCase
@Reducer
public struct SplashReducer {
public init() {}
- @ObservableState
+ private enum Constants {
+ static let tokenCheckDelay: Duration = .seconds(1.5)
+ }
+
public struct State: Equatable {
+ public var isCheckingToken = false
+ public var hasValidToken = false
public init() {}
}
@@ -32,25 +38,26 @@ public struct SplashReducer {
//MARK: - ViewAction
@CasePathable
public enum View {
-
+ case onAppear
}
-
//MARK: - AsyncAction 비동기 처리 액션
public enum AsyncAction: Equatable {
-
+ case checkToken
}
//MARK: - 앱내에서 사용하는 액션
public enum InnerAction: Equatable {
+ case tokenCheckResult(Bool)
}
//MARK: - NavigationAction
public enum NavigationAction: Equatable {
case presentHome
-
+ case presentAuth
}
+ @Dependency(\.keychainManager) var keychainManager
public var body: some Reducer {
BindingReducer()
@@ -81,7 +88,9 @@ extension SplashReducer {
action: View
) -> Effect {
switch action {
-
+ case .onAppear:
+ state.isCheckingToken = true
+ return .send(.async(.checkToken))
}
}
@@ -90,7 +99,21 @@ extension SplashReducer {
action: AsyncAction
) -> Effect {
switch action {
-
+ case .checkToken:
+ return .run { send in
+ // 키체인에서 액세스 토큰 확인
+ let token = await keychainManager.accessToken()
+ let hasToken = token != nil && !token!.isEmpty
+
+ // 1.5초 스플래시 시간 후 결과 전달
+ do {
+ try await Task.sleep(for: Constants.tokenCheckDelay)
+ await send(.inner(.tokenCheckResult(hasToken)))
+ } catch {
+ // Task 취소 또는 기타 에러 처리
+ await send(.inner(.tokenCheckResult(hasToken)))
+ }
+ }
}
}
@@ -102,6 +125,8 @@ extension SplashReducer {
case .presentHome:
return .none
+ case .presentAuth:
+ return .none
}
}
@@ -110,7 +135,17 @@ extension SplashReducer {
action: InnerAction
) -> Effect {
switch action {
-
+ case .tokenCheckResult(let hasToken):
+ state.isCheckingToken = false
+ state.hasValidToken = hasToken
+
+ if hasToken {
+ // 토큰이 있으면 메인 화면으로
+ return .send(.navigation(.presentHome))
+ } else {
+ // 토큰이 없으면 로그인 화면으로
+ return .send(.navigation(.presentAuth))
+ }
}
}
}
diff --git a/Projects/Presentation/Splash/Sources/View/SplashView.swift b/Projects/Presentation/Splash/Sources/View/SplashView.swift
index 0585d32..709bc20 100644
--- a/Projects/Presentation/Splash/Sources/View/SplashView.swift
+++ b/Projects/Presentation/Splash/Sources/View/SplashView.swift
@@ -12,6 +12,10 @@ import ComposableArchitecture
public struct SplashView: View {
@Bindable var store: StoreOf
+ @State private var scale: CGFloat = 1.0
+ @State private var bgOpacity: Double = 1.0
+ @State private var logoOpacity: Double = 1.0
+ @State private var isFinished = false
public init(store: StoreOf) {
self.store = store
@@ -19,16 +23,35 @@ public struct SplashView: View {
public var body: some View {
ZStack {
- Color.white
- .edgesIgnoringSafeArea(.all)
-
-
- VStack {
- Text("hello")
- }
+ // 배경
+ Color.black
+ .opacity(bgOpacity)
+ .ignoresSafeArea()
+
+ // 로고
+ Text("Uber")
+ .font(.system(size: 48, weight: .black))
+ .foregroundColor(.white)
+ .scaleEffect(scale)
+ .opacity(logoOpacity)
}
.onAppear {
- store.send(.navigation(.presentHome))
+ // 토큰 확인 시작
+ store.send(.view(.onAppear))
+
+ // 1단계: 로고 확대
+ withAnimation(.easeIn(duration: 0.6).delay(0.3)) {
+ scale = 30.0
+ }
+ // 2단계: 페이드 아웃
+ withAnimation(.easeIn(duration: 0.3).delay(0.7)) {
+ logoOpacity = 0
+ bgOpacity = 0
+ }
+ // 3단계: 완료 처리
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) {
+ isFinished = true
+ }
}
}
}
diff --git a/Projects/Shared/DesignSystem/Project.swift b/Projects/Shared/DesignSystem/Project.swift
index e070308..1167fb2 100644
--- a/Projects/Shared/DesignSystem/Project.swift
+++ b/Projects/Shared/DesignSystem/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "DesignSystem",
bundleId: .appBundleID(name: ".DesignSystem"),
product: .staticFramework,
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/arrowRight.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/arrowRight.imageset/Contents.json
new file mode 100644
index 0000000..09ce4cc
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/arrowRight.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "arrowRight.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/arrowRight.imageset/arrowRight.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/arrowRight.imageset/arrowRight.svg
new file mode 100644
index 0000000..caa9154
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/arrowRight.imageset/arrowRight.svg
@@ -0,0 +1,3 @@
+
diff --git a/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/check.imageset/Contents.json
similarity index 69%
rename from Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/check.imageset/Contents.json
index eb87897..b09981a 100644
--- a/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/check.imageset/Contents.json
@@ -1,6 +1,7 @@
{
- "colors" : [
+ "images" : [
{
+ "filename" : "check.svg",
"idiom" : "universal"
}
],
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/check.imageset/check.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/check.imageset/check.svg
new file mode 100644
index 0000000..c59090a
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/check.imageset/check.svg
@@ -0,0 +1,4 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/Contents.json
new file mode 100644
index 0000000..4ac1d90
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "noCheck.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/noCheck.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/noCheck.svg
new file mode 100644
index 0000000..4f6faec
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/noCheck.svg
@@ -0,0 +1,4 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json
new file mode 100644
index 0000000..b88864d
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "onBoardingLogo1.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/onBoardingLogo1.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/onBoardingLogo1.png
new file mode 100644
index 0000000..7dc92fd
Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/onBoardingLogo1.png differ
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json
new file mode 100644
index 0000000..f80034b
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "onBoardingLogo2.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/onBoardingLogo2.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/onBoardingLogo2.png
new file mode 100644
index 0000000..68ed389
Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/onBoardingLogo2.png differ
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json
new file mode 100644
index 0000000..5e7cba6
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "onBoardingLogo3.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/onBoardingLogo3.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/onBoardingLogo3.png
new file mode 100644
index 0000000..0025bd5
Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/onBoardingLogo3.png differ
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/appleMap.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/appleMap.imageset/Contents.json
new file mode 100644
index 0000000..29cd76e
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/appleMap.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "appleMap.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/appleMap.imageset/appleMap.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/appleMap.imageset/appleMap.png
new file mode 100644
index 0000000..a8431ff
Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/appleMap.imageset/appleMap.png differ
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json
new file mode 100644
index 0000000..037ecfc
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "googleMap.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/googleMap.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/googleMap.png
new file mode 100644
index 0000000..83da01f
Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/googleMap.png differ
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/naverMap.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/naverMap.imageset/Contents.json
new file mode 100644
index 0000000..66529e7
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/naverMap.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "naverMap.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/naverMap.imageset/naverMap.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/naverMap.imageset/naverMap.png
new file mode 100644
index 0000000..f60b3fc
Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/naverMap.imageset/naverMap.png differ
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/google.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/google.imageset/Contents.json
new file mode 100644
index 0000000..b627047
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/google.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "google.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/google.imageset/google.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/google.imageset/google.svg
new file mode 100644
index 0000000..68597e4
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/social/google.imageset/google.svg
@@ -0,0 +1,6 @@
+
diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift
index 53d3d11..f4b653e 100644
--- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift
+++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift
@@ -9,104 +9,43 @@ import SwiftUI
public extension ShapeStyle where Self == Color {
- // MARK: - Static Basic
+ // Gray
+ static var gray100: Color { .init(hex: "FFFFFF") }
+ static var gray200: Color { .init(hex: "F5F5F5") }
+ static var gray300: Color { .init(hex: "EDEDED") }
+ static var gray400: Color { .init(hex: "D4D4D4") }
+ static var gray500: Color { .init(hex: "BABABA") }
+ static var gray600: Color { .init(hex: "A1A1A1") }
+ static var gray700: Color { .init(hex: "878787") }
+ static var gray800: Color { .init(hex: "545454") }
+ static var gray900: Color { .init(hex: "181818") }
+ static var lightGray: Color { .init(hex: "CCCCCC") }
+ static var mediumGray: Color { .init(hex: "6C6C6C")}
+
+ static var enableColor: Color { .init(hex: "E2E2E2")}
+
+
+ // ORANGE
+ static var orange100: Color { .init(hex: "FFF6F5") }
+ static var orange200: Color { .init(hex: "FFE8E5") }
+ static var orange300: Color { .init(hex: "FFD1CC") }
+ static var orange400: Color { .init(hex: "FFBEB8") }
+ static var orange500: Color { .init(hex: "FFB1A8") }
+ static var orange600: Color { .init(hex: "FF9A8F") }
+ static var orange700: Color { .init(hex: "FF6B5C") }
+ static var orange800: Color { .init(hex: "FF3C27") }
+ static var orange900: Color { .init(hex: "F51700") }
+
+ //NAVY
+ static var navy100: Color { .init(hex: "DAE1F2") }
+ static var navy200: Color { .init(hex: "C2D0F2") }
+ static var navy300: Color { .init(hex: "98ABD9") }
+ static var navy400: Color { .init(hex: "6B81B2") }
+ static var navy500: Color { .init(hex: "4D6399") }
+ static var navy600: Color { .init(hex: "334A80") }
+ static var navy700: Color { .init(hex: "223B73") }
+ static var navy800: Color { .init(hex: "12234D") }
+ static var navy900: Color { .init(hex: "0C1834") }
- static var staticWhite: Color { .init(hex: "FFFFFF") }
- static var staticBlack: Color { .init(hex: "0C0E0F") }
-
- // MARK: - Static Text
-
- static var textPrimary: Color { .init(hex: "FFFFFF") }
- static var textSecondary: Color { .init(hex: "EAEAEA") }
- static var textSecondary100: Color { .init(hex: "525252") }
- static var textInactive: Color { .init(hex: "70737C47").opacity(0.28) }
-
- // MARK: - Static Background
-
- static var backGroundPrimary: Color { .init(hex: "0C0E0F") }
- static var backGroundSecondary: Color { .init(hex: "F2F2F7") }
- static var backgroundInverse: Color { .init(hex: "FFFFFF") }
-
- // MARK: - Static Border
-
- static var borderInactive: Color { .init(hex: "C6C6C6") }
- static var borderDisabled: Color { .init(hex: "323537") }
- static var borderInverse: Color { .init(hex: "202325") }
-
- // MARK: - Static Status
-
- static var statusFocus: Color { .init(hex: "0D82F9") }
- static var statusCautionary: Color { .init(hex: "FD5D08") }
- static var statusError: Color { .init(hex: "FD1008") }
-
- // MARK: - Primitives
-
- static var grayBlack: Color { .init(hex: "1A1A1A") }
- static var gray70: Color { .init(hex: "525252") }
- static var gray80: Color { .init(hex: "323537") }
- static var gray60: Color { .init(hex: "6F6F6F") }
- static var gray40: Color { .init(hex: "A8A8A8") }
- static var gray90: Color { .init(hex: "202325") }
- static var grayError: Color { .init(hex: "FF5050") }
- static var grayWhite: Color { .init(hex: "FFFFFF") }
- static var grayPrimary: Color { .init(hex: "0099FF") }
- static var mediumGray: Color { .init(hex: "8E8E93") }
- static var mediumGray100: Color { .init(hex: "C6C6CF") }
-
- // MARK: - Surface
-
- static var surfaceBackground: Color { .init(hex: "1A1A1A") }
- static var surfaceElevated: Color { .init(hex: "4D4D4D").opacity(0.4) }
- static var surfaceNormal: Color { .init(hex: "FFFFFF") }
- static var surfaceAccent: Color { .init(hex: "E6E6E6") }
- static var surfaceDisable: Color { .init(hex: "808080") }
- static var surfaceEnable: Color { .init(hex: "0099FF") }
- static var surfaceError: Color { .init(hex: "FF5050").opacity(0.2) }
-
- // MARK: - TextIcon
-
- static var onBackground: Color { .init(hex: "FFFFFF") }
- static var onNormal: Color { .init(hex: "1A1A1A") }
- static var onDisabled: Color { .init(hex: "4D4D4D").opacity(0.4) }
- static var onError: Color { .init(hex: "FF5050") }
-
- // MARK: - NatureBlue
-
- static var blue10: Color { .init(hex: "F5F8FF") }
- static var blue20: Color { .init(hex: "E1EAFF") }
- static var blue30: Color { .init(hex: "C1D3FF") }
- static var blue40: Color { .init(hex: "0D82F9") }
- static var blue50: Color { .init(hex: "0c75e0") }
- static var blue60: Color { .init(hex: "0a68c7") }
- static var blue70: Color { .init(hex: "0a62bb") }
- static var blue80: Color { .init(hex: "084E95") }
- static var blue90: Color { .init(hex: "063A70") }
- static var blue100: Color { .init(hex: "052E57") }
- static var dangerBlue: Color { .init(hex: "0D82F9") }
-
- // MARK: - NatureRed
-
- static var red10: Color { .init(hex: "ffe7e6") }
- static var red20: Color { .init(hex: "ffdbda") }
- static var red30: Color { .init(hex: "feb5b2") }
- static var red40: Color { .init(hex: "fd1008") }
- static var red50: Color { .init(hex: "e40e07") }
- static var red60: Color { .init(hex: "ca0d06") }
- static var red70: Color { .init(hex: "be0c06") }
- static var red80: Color { .init(hex: "980a05") }
- static var red90: Color { .init(hex: "720704") }
- static var red100: Color { .init(hex: "590603") }
-
- static var basicBlack: Color { .init(hex: "1A1A1A") }
- static var gray200: Color { .init(hex: "E6E6E6") }
- static var gray300: Color { .init(hex: "8F8F8F") }
- static var gray400: Color { .init(hex: "B3B3B3") }
- static var gray600: Color { .init(hex: "808080") }
- static var gray800: Color { .init(hex: "4D4D4D") }
-
- static var error: Color { .init(hex: "FF5050") }
- static var basicBlue: Color { .init(hex: "0099FF") }
-
- static var basicBlackDimmed: Color { .init(hex: "#333332").opacity(0.7) }
}
diff --git a/Projects/Shared/DesignSystem/Sources/CustomFont/CustomSize.swift b/Projects/Shared/DesignSystem/Sources/CustomFont/CustomSize.swift
index ffd4af3..c1cfce9 100644
--- a/Projects/Shared/DesignSystem/Sources/CustomFont/CustomSize.swift
+++ b/Projects/Shared/DesignSystem/Sources/CustomFont/CustomSize.swift
@@ -8,155 +8,78 @@
import Foundation
public enum CustomSizeFont {
- case headline1Semibold
- case headline2Semibold
- case headline3Semibold
- case headline4Semibold
- case headline5Bold
- case headline6NormalMedium
- case headline6Bold
- case headline7Medium
- case headline7Semibold
-
- case tilte1NormalBold
- case tilte1NormalMedium
- case title2NormalBold
- case title2NormalMedium
- case title3NormalBold
- case title3NormalMedium
- case title3NormalRegular
-
- case body1NormalBold
- case body1NormalMedium
- case body1NormalRegular
- case body2NormalBold
- case body2NormalMedium
- case body2NormalRegular
- case body3NormalBold
- case body3NormalMedium
- case body3NormalRegular
- case body4NormalRegular
- case body4NormalMedium
-
+ case heading0
+ case heading1
+ case heading2
+
+ case titleBold
+ case titleRegular
+
+ case bodyBold
+ case bodyMedium
+ case bodyRegular
+
+ case body2Bold
+ case body2Medium
+ case body2Regular
+
+ case caption
+
public var size: CGFloat {
switch self {
- case .headline1Semibold:
- return 88
- case .headline2Semibold:
- return 74
- case .headline3Semibold:
- return 68
- case .headline4Semibold:
- return 56
- case .headline5Bold:
- return 44
- case .headline6NormalMedium:
- return 38
- case .headline6Bold:
- return 38
- case .headline7Medium:
- return 32
- case .headline7Semibold:
- return 32
-
- case .tilte1NormalBold:
- return 28
- case .tilte1NormalMedium:
- return 28
- case .title2NormalBold:
- return 24
- case .title2NormalMedium:
- return 24
- case .title3NormalBold:
- return 20
- case .title3NormalMedium:
- return 20
- case .title3NormalRegular:
- return 20
-
- case .body1NormalBold:
- return 18
- case .body1NormalMedium:
- return 18
- case .body1NormalRegular:
- return 18
- case .body2NormalBold:
- return 16
- case .body2NormalMedium:
- return 16
- case .body2NormalRegular:
- return 16
- case .body3NormalBold:
- return 14
- case .body3NormalMedium:
- return 14
- case .body3NormalRegular:
- return 14
- case .body4NormalRegular:
- return 12
- case .body4NormalMedium:
- return 12
+ case .heading0:
+ return 28
+ case .heading1:
+ return 24
+ case .heading2:
+ return 22
+ case .titleBold:
+ return 18
+ case .titleRegular:
+ return 18
+ case .bodyBold:
+ return 16
+ case .bodyMedium:
+ return 16
+ case .bodyRegular:
+ return 16
+ case .body2Bold:
+ return 14
+ case .body2Medium:
+ return 14
+ case .body2Regular:
+ return 14
+ case .caption:
+ return 12
}
}
-
+
public var fontFamily: PretendardFontFamily {
switch self {
- case .headline1Semibold:
- return .SemiBold
- case .headline2Semibold:
- return .SemiBold
- case .headline3Semibold:
- return .SemiBold
- case .headline4Semibold:
- return .SemiBold
- case .headline5Bold:
- return .Bold
- case .headline6NormalMedium:
- return .Medium
- case .headline6Bold:
- return .Bold
- case .headline7Medium:
- return .Medium
- case .headline7Semibold:
- return .Medium
-
- case .tilte1NormalBold:
- return .Bold
- case .tilte1NormalMedium:
- return .Medium
- case .title2NormalBold:
- return .Bold
- case .title2NormalMedium:
- return .Medium
- case .title3NormalBold:
- return .Bold
- case .title3NormalMedium:
- return .Medium
- case .title3NormalRegular:
- return .Regular
-
- case .body1NormalBold:
- return .Bold
- case .body1NormalMedium:
- return .Medium
- case .body1NormalRegular:
- return .Regular
- case .body2NormalBold:
- return .Bold
- case .body2NormalMedium:
- return .Medium
- case .body2NormalRegular:
- return .Regular
- case .body3NormalBold:
- return .Bold
- case .body3NormalMedium:
- return .Medium
- case .body3NormalRegular:
- return .Regular
- case .body4NormalRegular:
- return .Regular
- case .body4NormalMedium:
- return .Medium
+ case .heading0:
+ return .SemiBold
+ case .heading1:
+ return .SemiBold
+ case .heading2:
+ return .SemiBold
+ case .titleBold:
+ return .Bold
+ case .titleRegular:
+ return .Regular
+ case .bodyBold:
+ return .SemiBold
+ case .bodyMedium:
+ return .Medium
+ case .bodyRegular:
+ return .Regular
+ case .body2Bold:
+ return .SemiBold
+ case .body2Medium:
+ return .Medium
+ case .body2Regular:
+ return .Regular
+ case .caption:
+ return .Regular
}
}
}
diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift
index ecfa67c..3e1901f 100644
--- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift
+++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift
@@ -8,59 +8,24 @@
import Foundation
public enum ImageAsset: String {
-
- case managementProfile
- case appLogo
- case qrCode
- case eventGenerate
- case arrowBack
- case arrowBackWhite
- case plus
- case logo
- case pet
- case arrow_down
- case arrow_up
- case editEvent
- case user
- case info
- case closeGray
-
+
// MARK: - 소셜로그인 버튼
-
- case appleLogin
- case googleLogin
case google
- // MARK: - 회원가입
-
- case backButton
- case error
- case close
- case errorClose
- case disableSelectPart
- case activeSelectPart
- case acitveSelectTeam
-
- case empty
-
- // MARK: - 멤버 출석 현황
-
- case danger
-
-
- // MARK: - attandance
- case abesent_icons
- case late_icons
- case present_icons
- case thd_icons
- case stamp
- case late_stamp
- case present_stamp
- case profileBack
-
- // MARK: - QR
- case qrCheck
- case edit
- case editAttendance
+ //Mark: - 버튼들
+ case noCheck
+ case check
+ case arrowRight
+
+ // MARK: - 지도
+ case naverMap
+ case googleMap
+ case appleMap
+
+ case onBoardingLogo1
+ case onBoardingLogo2
+ case onBoardingLogo3
+
+ case none
}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomModal.swift b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomAlert.swift
similarity index 91%
rename from Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomModal.swift
rename to Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomAlert.swift
index b1c2b0c..58f1f7b 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomModal.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomAlert.swift
@@ -81,7 +81,7 @@ struct CustomAlert: View {
var body: some View {
ZStack {
- Color.backGroundPrimary
+ Color.gray100
.opacity(0.68)
.edgesIgnoringSafeArea(.all)
@@ -89,11 +89,11 @@ struct CustomAlert: View {
VStack(alignment: .center, spacing: 4) {
Text(title)
.pretendardFont(family: .Bold, size: 20)
- .foregroundStyle(.textPrimary)
-
+ .foregroundStyle(.gray900)
+
Text(message)
.pretendardFont(family: .Regular, size: 14)
- .foregroundStyle(.textSecondary)
+ .foregroundStyle(.gray800)
}
Button {
@@ -101,18 +101,18 @@ struct CustomAlert: View {
} label: {
Text("확인")
.pretendardFont(family: .Medium, size: 14)
- .foregroundStyle(.staticWhite)
+ .foregroundStyle(.gray100)
.frame(maxWidth: .infinity)
.frame(height: 38)
}
- .background(.blue40)
+ .background(.gray900)
.clipShape(.rect(cornerRadius: 99))
.contentShape(.rect(cornerRadius: 99))
}
.padding(.vertical, 36)
.padding(.horizontal, 24)
.frame(width: 288)
- .background(.backGroundPrimary)
+ .background(.gray100)
.clipShape(.rect(cornerRadius: 28))
}
}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift
index 5b193b0..2847d23 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift
@@ -8,7 +8,6 @@
import SwiftUI
import ComposableArchitecture
-@ObservableState
public struct CustomAlertState: Equatable {
public let title: String
public let message: String
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift
index 441b034..5885aa8 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift
@@ -67,14 +67,14 @@ struct CustomConfirmationPopup: View {
VStack(alignment: .center, spacing: 24) {
VStack(alignment: .center, spacing: 8) {
Text(title)
- .pretendardCustomFont(textStyle: .title3NormalBold)
- .foregroundStyle(.staticWhite)
+ .pretendardCustomFont(textStyle: .bodyBold)
+ .foregroundStyle(.white)
.multilineTextAlignment(.center)
if !message.isEmpty {
Text(message)
- .pretendardCustomFont(textStyle: .body3NormalRegular)
- .foregroundStyle(.textSecondary)
+ .pretendardCustomFont(textStyle: .bodyBold)
+ .foregroundStyle(.gray100)
.multilineTextAlignment(.center)
}
}
@@ -85,11 +85,11 @@ struct CustomConfirmationPopup: View {
} label: {
Text(confirmTitle)
.pretendardFont(family: .Medium, size: 16)
- .foregroundStyle(.staticWhite)
+ .foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
- .background(.gray80)
+ .background(.gray800)
.clipShape(.rect(cornerRadius: 20))
.contentShape(.rect(cornerRadius: 20))
@@ -98,11 +98,11 @@ struct CustomConfirmationPopup: View {
} label: {
Text(cancelTitle)
.pretendardFont(family: .Medium, size: 16)
- .foregroundStyle(.staticWhite)
+ .foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
- .background(.blue40)
+ .background(.gray800)
.clipShape(.rect(cornerRadius: 20))
.contentShape(.rect(cornerRadius: 20))
}
@@ -110,7 +110,7 @@ struct CustomConfirmationPopup: View {
.padding(.vertical, 32)
.padding(.horizontal, 24)
.frame(width: 320)
- .background(.gray90)
+ .background(.gray800)
.clipShape(.rect(cornerRadius: 20))
.onTapGesture {}
}
@@ -118,14 +118,14 @@ struct CustomConfirmationPopup: View {
private var consentContent: some View {
VStack(alignment: .center, spacing: 16) {
Text(title)
- .pretendardCustomFont(textStyle: .title3NormalBold)
- .foregroundStyle(.staticWhite)
+ .pretendardCustomFont(textStyle: .titleBold)
+ .foregroundStyle(.white)
.multilineTextAlignment(.center)
if !message.isEmpty {
Text(message)
- .pretendardCustomFont(textStyle: .body3NormalRegular)
- .foregroundStyle(.textSecondary)
+ .pretendardCustomFont(textStyle: .body2Bold)
+ .foregroundStyle(.gray800)
.multilineTextAlignment(.center)
}
@@ -137,13 +137,13 @@ struct CustomConfirmationPopup: View {
}
} label: {
RoundedRectangle(cornerRadius: 4)
- .stroke(.gray60, lineWidth: 1)
+ .stroke(.gray800, lineWidth: 1)
.frame(width: 15, height: 15)
.overlay {
if isChecked {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
- .foregroundStyle(.staticWhite)
+ .foregroundStyle(.white)
}
}
.padding(6)
@@ -152,9 +152,9 @@ struct CustomConfirmationPopup: View {
.buttonStyle(.plain)
Text(checkboxTitle)
- .pretendardCustomFont(textStyle: .body3NormalRegular)
- .foregroundStyle(.staticWhite)
- .underline(true, color: .mediumGray)
+ .pretendardCustomFont(textStyle: .bodyMedium)
+ .foregroundStyle(.white)
+ .underline(true, color: .gray800)
.onTapGesture {
onPolicyTap()
}
@@ -164,7 +164,7 @@ struct CustomConfirmationPopup: View {
.padding(.vertical, 24)
.padding(.horizontal, 20)
.frame(width: 300)
- .background(.gray90)
+ .background(.gray800)
.clipShape(.rect(cornerRadius: 20))
.onTapGesture {}
}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/BackButton.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/BackButton.swift
index 5fffcfb..79e4dc6 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/BackButton.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Button/BackButton.swift
@@ -18,7 +18,7 @@ public struct NavigationBackButton: View {
public var body: some View {
HStack {
- Image(asset: .backButton)
+ Image(asset: .none)
.resizable()
.scaledToFit()
.frame(width: 12, height: 20)
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButton.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButton.swift
index c414d13..a5dc1c3 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButton.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButton.swift
@@ -10,13 +10,13 @@ import SwiftUI
public struct CustomButton: View {
private let action: () -> Void
private let title: String
- private let config: DDDCustomButtonConfig
+ private let config: TimeSpotCustomButtonConfig
private var isEnable: Bool = false
public init(
action: @escaping () -> Void,
title: String,
- config: DDDCustomButtonConfig,
+ config: TimeSpotCustomButtonConfig,
isEnable: Bool = false
) {
self.title = title
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButtonConfig.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButtonConfig.swift
index 9c73dfd..159d642 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButtonConfig.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Button/CustomButtonConfig.swift
@@ -7,30 +7,17 @@
import SwiftUI
-public class CustomButtonConfig: DDDCustomButtonConfig {
- static public func create() -> DDDCustomButtonConfig {
- let config = DDDCustomButtonConfig(
+public class CustomButtonConfig: TimeSpotCustomButtonConfig {
+ static public func create() -> TimeSpotCustomButtonConfig {
+ let config = TimeSpotCustomButtonConfig(
cornerRadius: 30,
- enableFontColor: Color.grayWhite,
- enableBackgroundColor: Color.surfaceEnable,
- frameHeight: 48,
- disableFontColor: Color.grayWhite,
- disableBackgroundColor: Color.blue30
+ enableFontColor: .gray100,
+ enableBackgroundColor: .navy900,
+ frameHeight: 60,
+ disableFontColor: .gray900,
+ disableBackgroundColor: .enableColor
)
return config
}
-
- static public func createDateButton() -> DDDCustomButtonConfig {
- let config = DDDCustomButtonConfig(
- cornerRadius: 30,
- enableFontColor: .grayWhite,
- enableBackgroundColor: .statusFocus,
- frameHeight: 58,
- disableFontColor: .grayWhite,
- disableBackgroundColor: .blue20
- )
- return config
- }
-
}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/SelectPartItem.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/SelectPartItem.swift
deleted file mode 100644
index a205cfe..0000000
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/SelectPartItem.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-//
-// SelectPartItem.swift
-// DesignSystem
-//
-// Created by Wonji Suh on 11/3/24.
-//
-
-import SwiftUI
-
-public struct SelectPartItem: View {
- private let content: String
- private let isActive: Bool
- private let completion: () -> Void
-
- public init(
- content: String,
- isActive: Bool,
- completion: @escaping () -> Void
- ) {
- self.content = content
- self.isActive = isActive
- self.completion = completion
- }
-
- public var body: some View {
- VStack {
- RoundedRectangle(cornerRadius: 16)
- .stroke(isActive ? Color.statusFocus : Color.clear, style: .init(lineWidth: 2))
- .frame(height: 58)
- .background(Color.gray90)
- .cornerRadius(16)
- .overlay {
- HStack {
- Text(content)
- .pretendardCustomFont(textStyle: .body1NormalMedium)
- .foregroundStyle(Color.grayWhite)
-
- Spacer()
-
- Image(asset: isActive ? .activeSelectPart : .disableSelectPart)
- .resizable()
- .scaledToFit()
- .frame(width: 20, height: 20)
- }
- .padding(.horizontal, 20)
- .onTapGesture {
- completion()
- }
- }
- .onTapGesture {
- completion()
- }
- }
- .padding(.horizontal, 24)
- }
-}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/SelectTeamIteam.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/SelectTeamIteam.swift
deleted file mode 100644
index 410f980..0000000
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/SelectTeamIteam.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-//
-// SelectTeamIteam.swift
-// DesignSystem
-//
-// Created by Wonji Suh on 11/4/24.
-//
-
-import SwiftUI
-
-public struct SelectTeamIteam: View {
- private let content: String
- private let isActive: Bool
- private let completion: () -> Void
-
- public init(
- content: String,
- isActive: Bool,
- completion: @escaping () -> Void
- ) {
- self.content = content
- self.isActive = isActive
- self.completion = completion
- }
-
- public var body: some View {
- VStack {
- RoundedRectangle(cornerRadius: 16)
- .stroke(isActive ? .statusFocus : Color.clear, style: .init(lineWidth: 2))
- .frame(height: 58)
- .background(.gray90)
- .cornerRadius(16)
- .overlay {
- HStack {
- Text(content)
- .pretendardCustomFont(textStyle: .body1NormalMedium)
- .foregroundStyle(isActive ? Color.textPrimary : Color.grayWhite)
-
- Spacer()
-
- Image(asset: isActive ? .activeSelectPart : .disableSelectPart)
- .resizable()
- .scaledToFit()
- .frame(width: 20, height: 20)
- }
- .padding(.horizontal, 20)
- .onTapGesture {
- completion()
- }
- }
- .onTapGesture {
- completion()
- }
- }
- .padding(.horizontal, 24)
- }
-}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/StepNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/StepNavigationBar.swift
index 7edf800..cf089f4 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/StepNavigationBar.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Button/StepNavigationBar.swift
@@ -21,7 +21,7 @@ public struct StepNavigationBar: View {
public var body: some View {
HStack {
- Image(asset: .backButton)
+ Image(asset: .none)
.resizable()
.scaledToFit()
.frame(width: 12, height: 20)
@@ -37,7 +37,7 @@ public struct StepNavigationBar: View {
Rectangle()
.foregroundColor(.clear)
.frame(maxWidth: 78, minHeight: 3, maxHeight: 3)
- .background(step <= activeStep ? Color.grayWhite : Color.gray80)
+ .background(step <= activeStep ? .white : .blue)
.clipShape(Capsule())
}
}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Button/DDDCustomButtonConfig.swift b/Projects/Shared/DesignSystem/Sources/Ui/Button/TimeSpotCustomButtonConfig.swift
similarity index 90%
rename from Projects/Shared/DesignSystem/Sources/Ui/Button/DDDCustomButtonConfig.swift
rename to Projects/Shared/DesignSystem/Sources/Ui/Button/TimeSpotCustomButtonConfig.swift
index 2c57c7b..7e89776 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Button/DDDCustomButtonConfig.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Button/TimeSpotCustomButtonConfig.swift
@@ -1,5 +1,5 @@
//
-// DDDCustomButtonConfig.swift
+// TimeSpotCustomButtonConfig.swift
// DesignSystem
//
// Created by Wonji Suh on 11/2/24.
@@ -7,7 +7,7 @@
import SwiftUI
-public class DDDCustomButtonConfig {
+public class TimeSpotCustomButtonConfig {
public let cornerRadius: CGFloat
public let enableFontColor: Color
public let enableBackgroundColor:Color
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift b/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift
new file mode 100644
index 0000000..71126bb
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift
@@ -0,0 +1,218 @@
+//
+// CustomModalModifier.swift
+// DesignSystem
+//
+// Created by Wonji Suh on 3/19/26.
+//
+
+import SwiftUI
+
+// Environment key for modal dismiss
+public struct ModalDismissKey: EnvironmentKey {
+ public static let defaultValue: (() -> Void) = { }
+}
+
+public extension EnvironmentValues {
+ var modalDismiss: () -> Void {
+ get { self[ModalDismissKey.self] }
+ set { self[ModalDismissKey.self] = newValue }
+ }
+}
+
+public enum ModalHeight {
+ case fraction(CGFloat)
+ case fixed(CGFloat)
+ case auto
+}
+
+public struct CustomModalModifier: ViewModifier {
+ @Binding var item: Item?
+ let height: ModalHeight
+ let showDragIndicator: Bool
+ let modalContent: (Item) -> ModalContent
+
+ @State private var dragOffset: CGFloat = 0 // 드래그 오프셋
+ @State private var dismissOffset: CGFloat = 0 // 닫기 오프셋
+ @State private var isDragging: Bool = false // 드래그 중 상태
+
+ // MARK: - Animation Constants
+ private let slideDistance: CGFloat = 400 // 부드러운 슬라이드 거리
+ private let dismissThreshold: CGFloat = 100
+ private let slideAnimation = Animation.easeInOut(duration: 0.3) // 단순한 슬라이드
+ private let dragAnimation = Animation.linear(duration: 0.2) // 드래그도 단순하게
+
+ public init(
+ item: Binding- ,
+ height: ModalHeight = .auto,
+ showDragIndicator: Bool = true,
+ @ViewBuilder modalContent: @escaping (Item) -> ModalContent
+ ) {
+ self._item = item
+ self.height = height
+ self.showDragIndicator = showDragIndicator
+ self.modalContent = modalContent
+ }
+
+ public func body(content: Content) -> some View {
+ GeometryReader { geometry in
+ content
+ .overlay(
+ Group {
+ if item != nil {
+ modalOverlay(geometry: geometry)
+ }
+ }
+ )
+ }
+ }
+
+ // MARK: - Private Views
+ @ViewBuilder
+ private func modalOverlay(geometry: GeometryProxy) -> some View {
+ ZStack {
+ backgroundView
+ modalContentView(geometry: geometry)
+ }
+ }
+
+ private var backgroundView: some View {
+ Color.black.opacity(0.4)
+ .ignoresSafeArea()
+ .transition(.opacity.animation(.easeInOut(duration: 0.3)))
+ .onTapGesture { dismissModal() }
+ }
+
+ @ViewBuilder
+ private func modalContentView(geometry: GeometryProxy) -> some View {
+ VStack(spacing: 0) {
+ Spacer() // 상단 여백 유지
+
+ if let currentItem = item {
+ VStack(spacing: 0) {
+ dragIndicatorView
+ modalContent(currentItem)
+ .frame(height: modalHeightValue(for: geometry))
+ .environment(\.modalDismiss, dismissModal)
+ bottomSpacer(geometry: geometry)
+ }
+ .background(.white)
+ .clipShape(
+ UnevenRoundedRectangle(
+ topLeadingRadius: 30,
+ bottomLeadingRadius: 30,
+ bottomTrailingRadius: 30,
+ topTrailingRadius: 30
+ )
+ )
+ .offset(y: dragOffset + dismissOffset)
+ .animation(isDragging ? nil : .easeOut(duration: 0.2), value: dragOffset)
+ .onAppear {
+ // 밑에서 올라오는 애니메이션
+ dismissOffset = slideDistance
+ withAnimation(.easeOut(duration: 0.4)) {
+ dismissOffset = 0
+ }
+ }
+ .onChange(of: item) { oldValue, newValue in
+ // TCA에서 close 액션으로 destination이 nil이 되었을 때
+ if oldValue != nil && newValue == nil {
+ // 이미 애니메이션이 시작되지 않았다면 시작
+ if dismissOffset == 0 {
+ withAnimation(.easeOut(duration: 0.4)) {
+ dismissOffset = slideDistance
+ }
+ dragOffset = 0
+ }
+ }
+ }
+ .gesture(dragGesture)
+ .transition(.move(edge: .bottom))
+ .animation(.easeOut(duration: 0.3), value: item != nil)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var dragIndicatorView: some View {
+ if showDragIndicator {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 36, height: 5)
+ .padding(.top, 4)
+// .padding(.bottom, 12)
+ }
+ }
+
+ private func bottomSpacer(geometry: GeometryProxy) -> some View {
+ Spacer()
+ .frame(height: geometry.safeAreaInsets.bottom - 20)
+ }
+
+ // MARK: - Gesture
+ private var dragGesture: some Gesture {
+ DragGesture()
+ .onChanged { value in
+ isDragging = true
+ // 아래로만 드래그 허용
+ if value.translation.height > 0 {
+ dragOffset = value.translation.height
+ }
+ }
+ .onEnded { value in
+ isDragging = false
+ if value.translation.height > dismissThreshold {
+ dismissModal() // 부드러운 닫기
+ } else {
+ // 원래 위치로 돌아가기
+ withAnimation(.easeOut(duration: 0.2)) {
+ dragOffset = 0
+ }
+ }
+ }
+ }
+
+ // MARK: - Actions
+ private func dismissModal() {
+ withAnimation(.easeOut(duration: 0.3)) {
+ dismissOffset = slideDistance
+ }
+ dragOffset = 0
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ item = nil
+ }
+ }
+
+ // MARK: - Helper Methods
+ private func modalHeightValue(for geometry: GeometryProxy) -> CGFloat? {
+ let safeAreaBottom = geometry.safeAreaInsets.bottom
+ let availableHeight = geometry.size.height - safeAreaBottom
+
+ switch height {
+ case .fraction(let fraction):
+ return min(availableHeight * fraction, availableHeight)
+ case .fixed(let points):
+ return min(points, availableHeight)
+ case .auto:
+ return nil
+ }
+ }
+}
+
+public extension View {
+ func presentDSModal(
+ item: Binding
- ,
+ height: ModalHeight = .auto,
+ showDragIndicator: Bool = true,
+ @ViewBuilder content: @escaping (Item) -> ModalContent
+ ) -> some View {
+ modifier(
+ CustomModalModifier(
+ item: item,
+ height: height,
+ showDragIndicator: showDragIndicator,
+ modalContent: content
+ )
+ )
+ }
+}
+
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift
index 3ff1e38..3efd425 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift
@@ -28,15 +28,15 @@ public enum ToastType: Equatable {
public var backgroundColor: Color {
switch self {
case .success:
- return .gray40
+ return .gray700
case .error:
- return .gray40
+ return .gray700
case .warning:
- return .gray40
+ return .gray700
case .info:
- return .gray40
+ return .gray700
case .loading:
- return .gray60
+ return .gray700
}
}
diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift
index 13a9196..0826564 100644
--- a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift
+++ b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift
@@ -21,7 +21,7 @@ public struct ToastView: View {
// 메시지
Text(toast.message)
- .pretendardCustomFont(textStyle: .body1NormalBold)
+ .pretendardCustomFont(textStyle: .bodyBold)
.foregroundColor(.white)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Projects/Shared/Shared/Project.swift b/Projects/Shared/Shared/Project.swift
index 67c62d7..e352c50 100644
--- a/Projects/Shared/Shared/Project.swift
+++ b/Projects/Shared/Shared/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Shared",
bundleId: .appBundleID(name: ".Shared"),
product: .framework,
diff --git a/Projects/Shared/Utill/Project.swift b/Projects/Shared/Utill/Project.swift
index 8542661..bcf42f7 100644
--- a/Projects/Shared/Utill/Project.swift
+++ b/Projects/Shared/Utill/Project.swift
@@ -4,7 +4,7 @@ import DependencyPlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "Utill",
bundleId: .appBundleID(name: ".Utill"),
product: .staticFramework,
diff --git a/Projects/Shared/Utill/Sources/Date/Date+.swift b/Projects/Shared/Utill/Sources/Date/Date+.swift
index 452934f..fbfee4a 100644
--- a/Projects/Shared/Utill/Sources/Date/Date+.swift
+++ b/Projects/Shared/Utill/Sources/Date/Date+.swift
@@ -7,7 +7,6 @@
import Foundation
-import Model
public extension Date {
func formattedString() -> String {
diff --git a/Tuist/Package.swift b/Tuist/Package.swift
index 0fdc609..0578bca 100644
--- a/Tuist/Package.swift
+++ b/Tuist/Package.swift
@@ -26,13 +26,14 @@ let packageSettings = PackageSettings(
#endif
let package = Package(
- name: "MultiModuleTemplate",
+ name: "TimeSpot",
dependencies: [
- .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.18.0"),
- .package(url: "https://github.com/johnpatrickmorgan/TCACoordinators.git", exact: "0.11.1"),
+ .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.23.0"),
+ .package(url: "https://github.com/johnpatrickmorgan/TCACoordinators.git", exact: "0.14.0"),
.package(url: "https://github.com/Roy-wonji/WeaveDI.git", from: "3.4.0"),
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.0.0"),
.package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"),
.package(url: "https://github.com/openid/AppAuth-iOS.git", from: "2.0.0"),
+ .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "6.7.0"),
]
)
diff --git a/Tuist/Templates/Module/Project.stencil b/Tuist/Templates/Module/Project.stencil
index dcb057e..591d062 100644
--- a/Tuist/Templates/Module/Project.stencil
+++ b/Tuist/Templates/Module/Project.stencil
@@ -5,7 +5,7 @@ import ProjectTemplatePlugin
import ProjectTemplatePlugin
import DependencyPackagePlugin
-let project = Project.makeAppModule(
+let project = Project.makeModule(
name: "{{ name }}",
bundleId: .appBundleID(name: ".{{name}}"),
product: .staticFramework,