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,