diff --git a/.gitignore b/.gitignore index ad01647..61f33d5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ DerivedData/ *.xccheckout *.xcscmblueprint *.moved-aside +*.xcconfig # Swift Package Manager .build/ diff --git a/KillingPart.xcodeproj/project.pbxproj b/KillingPart.xcodeproj/project.pbxproj index 046c79d..3f05644 100644 --- a/KillingPart.xcodeproj/project.pbxproj +++ b/KillingPart.xcodeproj/project.pbxproj @@ -6,6 +6,14 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 12CE25AA2F3DC9BE0057A858 /* Development.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 12CE25A92F3DC9BE0057A858 /* Development.xcconfig */; }; + 12CE25AC2F3DCA940057A858 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 12CE25AB2F3DCA940057A858 /* Release.xcconfig */; }; + 12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0112F3F010000A00001 /* KakaoSDKCommon */; }; + 12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0122F3F010000A00001 /* KakaoSDKAuth */; }; + 12F7A0032F3F010000A00001 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0132F3F010000A00001 /* KakaoSDKUser */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 1231F12E2F372E5D00CFA51D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -27,6 +35,8 @@ 1231F11D2F372E5B00CFA51D /* KillingPart.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KillingPart.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1231F12D2F372E5D00CFA51D /* KillingPartTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KillingPartTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1231F1372F372E5D00CFA51D /* KillingPartUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KillingPartUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 12CE25A92F3DC9BE0057A858 /* Development.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Development.xcconfig; sourceTree = ""; }; + 12CE25AB2F3DCA940057A858 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -65,6 +75,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */, + 12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */, + 12F7A0032F3F010000A00001 /* KakaoSDKUser in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -88,6 +101,8 @@ 1231F1142F372E5B00CFA51D = { isa = PBXGroup; children = ( + 12CE25AB2F3DCA940057A858 /* Release.xcconfig */, + 12CE25A92F3DC9BE0057A858 /* Development.xcconfig */, 1231F11F2F372E5B00CFA51D /* KillingPart */, 1231F1302F372E5D00CFA51D /* KillingPartTests */, 1231F13A2F372E5D00CFA51D /* KillingPartUITests */, @@ -125,6 +140,9 @@ ); name = KillingPart; packageProductDependencies = ( + 12F7A0112F3F010000A00001 /* KakaoSDKCommon */, + 12F7A0122F3F010000A00001 /* KakaoSDKAuth */, + 12F7A0132F3F010000A00001 /* KakaoSDKUser */, ); productName = KillingPart; productReference = 1231F11D2F372E5B00CFA51D /* KillingPart.app */; @@ -208,6 +226,9 @@ ); mainGroup = 1231F1142F372E5B00CFA51D; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 1231F11E2F372E5B00CFA51D /* Products */; projectDirPath = ""; @@ -225,6 +246,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 12CE25AA2F3DC9BE0057A858 /* Development.xcconfig in Resources */, + 12CE25AC2F3DCA940057A858 /* Release.xcconfig in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -284,6 +307,7 @@ /* Begin XCBuildConfiguration section */ 1231F13F2F372E5D00CFA51D /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 12CE25A92F3DC9BE0057A858 /* Development.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -348,6 +372,7 @@ }; 1231F1402F372E5D00CFA51D /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 12CE25AB2F3DCA940057A858 /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -626,6 +651,35 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kakao/kakao-ios-sdk"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 12F7A0112F3F010000A00001 /* KakaoSDKCommon */ = { + isa = XCSwiftPackageProductDependency; + package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKCommon; + }; + 12F7A0122F3F010000A00001 /* KakaoSDKAuth */ = { + isa = XCSwiftPackageProductDependency; + package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKAuth; + }; + 12F7A0132F3F010000A00001 /* KakaoSDKUser */ = { + isa = XCSwiftPackageProductDependency; + package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKUser; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 1231F1152F372E5B00CFA51D /* Project object */; } diff --git a/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..fdc59d5 --- /dev/null +++ b/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "0a9a0565c23645eacdcd0d177a036429567ab510b257a7f2538ea8bb318bf8d2", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "3f99050e75bbc6fe71fc323adabb039756680016", + "version" : "5.11.1" + } + }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk", + "state" : { + "branch" : "master", + "revision" : "5978979157a5a0521c9c56fd0156aec794caa21c" + } + } + ], + "version" : 3 +} diff --git a/KillingPart/Assets.xcassets/Login/Contents.json b/KillingPart/Assets.xcassets/Login/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/KillingPart/Assets.xcassets/Login/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KillingPart/Assets.xcassets/Login/kakaoTalkBubble.imageset/Contents.json b/KillingPart/Assets.xcassets/Login/kakaoTalkBubble.imageset/Contents.json new file mode 100644 index 0000000..5c88e96 --- /dev/null +++ b/KillingPart/Assets.xcassets/Login/kakaoTalkBubble.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "maintabIcoChats.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/KillingPart/Assets.xcassets/Login/kakaoTalkBubble.imageset/maintabIcoChats.pdf b/KillingPart/Assets.xcassets/Login/kakaoTalkBubble.imageset/maintabIcoChats.pdf new file mode 100644 index 0000000..6bd0eea Binary files /dev/null and b/KillingPart/Assets.xcassets/Login/kakaoTalkBubble.imageset/maintabIcoChats.pdf differ diff --git a/KillingPart/Assets.xcassets/Login/loginTitle.imageset/Contents.json b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/Contents.json new file mode 100644 index 0000000..8f611f4 --- /dev/null +++ b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "loginTitle.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "loginTitle 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "loginTitle 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle 1.png b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle 1.png new file mode 100644 index 0000000..2121ce9 Binary files /dev/null and b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle 1.png differ diff --git a/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle 2.png b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle 2.png new file mode 100644 index 0000000..2121ce9 Binary files /dev/null and b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle 2.png differ diff --git a/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle.png b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle.png new file mode 100644 index 0000000..2121ce9 Binary files /dev/null and b/KillingPart/Assets.xcassets/Login/loginTitle.imageset/loginTitle.png differ diff --git a/KillingPart/Info.plist b/KillingPart/Info.plist index 2307b85..a57e406 100644 --- a/KillingPart/Info.plist +++ b/KillingPart/Info.plist @@ -2,6 +2,25 @@ + BASE_URL + $(BASE_URL) + KAKAO_NATIVE_APP_KEY + $(KAKAO_NATIVE_APP_KEY) + LSApplicationQueriesSchemes + + kakaokompassauth + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + kakao$(KAKAO_NATIVE_APP_KEY) + + + UIAppFonts Paperlogy-1Thin.ttf diff --git a/KillingPart/KillingPartApp.swift b/KillingPart/KillingPartApp.swift index 3239bc1..8338201 100644 --- a/KillingPart/KillingPartApp.swift +++ b/KillingPart/KillingPartApp.swift @@ -6,18 +6,37 @@ // import SwiftUI +import KakaoSDKAuth +import KakaoSDKCommon @main struct KillingPartApp: App { init() { if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" { AppFont.registerPaperlogyFonts() + configureKakaoSDK() } } var body: some Scene { WindowGroup { RootFlowView() + .onOpenURL { url in + if AuthApi.isKakaoTalkLoginUrl(url) { + _ = AuthController.handleOpenUrl(url: url) + } + } } } + + private func configureKakaoSDK() { + let appKey = (Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !appKey.isEmpty, appKey != "YOUR_KAKAO_NATIVE_APP_KEY" else { + return + } + + KakaoSDK.initSDK(appKey: appKey) + } } diff --git a/KillingPart/Models/KakaoSocialLoginModels.swift b/KillingPart/Models/KakaoSocialLoginModels.swift new file mode 100644 index 0000000..dfc7285 --- /dev/null +++ b/KillingPart/Models/KakaoSocialLoginModels.swift @@ -0,0 +1,11 @@ +import Foundation + +struct KakaoSocialLoginRequest: Encodable { + let accessToken: String +} + +struct KakaoSocialLoginResponse: Decodable { + let accessToken: String + let refreshToken: String + let isNew: Bool +} diff --git a/KillingPart/Resources/Videos/login.mp4 b/KillingPart/Resources/Videos/login.mp4 new file mode 100644 index 0000000..feeaa28 Binary files /dev/null and b/KillingPart/Resources/Videos/login.mp4 differ diff --git a/KillingPart/Services/APIConfiguration.swift b/KillingPart/Services/APIConfiguration.swift new file mode 100644 index 0000000..51edad9 --- /dev/null +++ b/KillingPart/Services/APIConfiguration.swift @@ -0,0 +1,25 @@ +import Foundation + +enum APIConfiguration { + static let baseURL: URL = { + guard + let baseURLString = Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String, + let baseURL = URL(string: baseURLString), + baseURL.scheme != nil, + baseURL.host != nil + else { + preconditionFailure( + "BASE_URL is missing or invalid (\(Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String ?? "nil")). Check your xcconfig values." + ) + } + + return baseURL + }() + + static func endpoint(path: String) -> URL { + let components = path.split(separator: "/").map(String.init) + return components.reduce(baseURL) { partialURL, component in + partialURL.appendingPathComponent(component) + } + } +} diff --git a/KillingPart/Services/AuthenticationService.swift b/KillingPart/Services/AuthenticationService.swift index 2724edd..bf7f36a 100644 --- a/KillingPart/Services/AuthenticationService.swift +++ b/KillingPart/Services/AuthenticationService.swift @@ -2,12 +2,129 @@ import Foundation protocol AuthenticationServicing { func login(email: String, password: String) async -> Bool + func loginWithKakao(accessToken: String) async throws -> KakaoSocialLoginResponse +} + +enum AuthenticationServiceError: LocalizedError { + case invalidKakaoAccessToken + case invalidResponse + case serverError(statusCode: Int, message: String?) + case decodingFailed + case requestEncodingFailed + + var errorDescription: String? { + switch self { + case .invalidKakaoAccessToken: + return "카카오 액세스 토큰이 유효하지 않아요." + case .invalidResponse: + return "서버 응답을 확인할 수 없어요." + case .serverError(let statusCode, let message): + if let message, !message.isEmpty { + print("로그인 처리에 실패했어요. (status: \(statusCode), message: \(message))") + return "로그인 처리에 실패했어요." + } + print("로그인 처리에 실패했어요. (status: \(statusCode)") + return "로그인 처리에 실패했어요." + case .decodingFailed: + return "로그인 응답 파싱에 실패했어요." + case .requestEncodingFailed: + return "로그인 요청 생성에 실패했어요." + } + } } struct AuthenticationService: AuthenticationServicing { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + func login(email: String, password: String) async -> Bool { try? await Task.sleep(for: .milliseconds(600)) return !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + func loginWithKakao(accessToken: String) async throws -> KakaoSocialLoginResponse { + let trimmedToken = accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedToken.isEmpty else { + throw AuthenticationServiceError.invalidKakaoAccessToken + } + + var request = URLRequest(url: APIConfiguration.endpoint(path: "/oauth2/kakao")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + request.httpBody = try JSONEncoder().encode( + KakaoSocialLoginRequest(accessToken: trimmedToken) + ) + } catch { + throw AuthenticationServiceError.requestEncodingFailed + } + + debugLogOutgoingRequest(request) + let (data, response) = try await session.data(for: request) + debugLogIncomingResponse(response, data: data) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthenticationServiceError.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + let responseMessage = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw AuthenticationServiceError.serverError( + statusCode: httpResponse.statusCode, + message: responseMessage + ) + } + + do { + return try JSONDecoder().decode(KakaoSocialLoginResponse.self, from: data) + } catch { + throw AuthenticationServiceError.decodingFailed + } + } + + private func debugLogOutgoingRequest(_ request: URLRequest) { + #if DEBUG + let method = request.httpMethod ?? "UNKNOWN" + let url = request.url?.absoluteString ?? "nil" + let headers = request.allHTTPHeaderFields ?? [:] + let bodyString = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "nil" + + print( + """ + [Network][Request] + method: \(method) + url: \(url) + headers: \(headers) + body: \(bodyString) + """ + ) + #endif + } + + private func debugLogIncomingResponse(_ response: URLResponse?, data: Data) { + #if DEBUG + if let httpResponse = response as? HTTPURLResponse { + let bodyString = String(data: data, encoding: .utf8) ?? "" + print( + """ + [Network][Response] + status: \(httpResponse.statusCode) + url: \(httpResponse.url?.absoluteString ?? "nil") + headers: \(httpResponse.allHeaderFields) + body: \(bodyString) + """ + ) + return + } + + print("[Network][Response] invalid response: \(String(describing: response))") + #endif + } } diff --git a/KillingPart/Services/KakaoLoginService.swift b/KillingPart/Services/KakaoLoginService.swift new file mode 100644 index 0000000..19db4f6 --- /dev/null +++ b/KillingPart/Services/KakaoLoginService.swift @@ -0,0 +1,79 @@ +import Foundation +import KakaoSDKUser + +protocol KakaoLoginServicing { + @MainActor + func login() async throws -> String +} + +enum KakaoLoginError: LocalizedError { + case missingNativeAppKey + case loginFailed(underlying: Error) + case missingAccessToken + + var errorDescription: String? { + switch self { + case .missingNativeAppKey: + return "KAKAO_NATIVE_APP_KEY 설정이 필요해요." + case .loginFailed: + return "카카오 로그인에 실패했어요. 다시 시도해 주세요." + case .missingAccessToken: + return "카카오 액세스 토큰을 가져오지 못했어요." + } + } +} + +struct KakaoLoginService: KakaoLoginServicing { + @MainActor + func login() async throws -> String { + let appKey = (Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !appKey.isEmpty, appKey != "YOUR_KAKAO_NATIVE_APP_KEY" else { + throw KakaoLoginError.missingNativeAppKey + } + + if UserApi.isKakaoTalkLoginAvailable() { + return try await loginWithKakaoTalk() + } + + return try await loginWithKakaoAccount() + } + + @MainActor + private func loginWithKakaoTalk() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + UserApi.shared.loginWithKakaoTalk { oauthToken, error in + if let error { + continuation.resume(throwing: KakaoLoginError.loginFailed(underlying: error)) + return + } + + guard let accessToken = oauthToken?.accessToken, !accessToken.isEmpty else { + continuation.resume(throwing: KakaoLoginError.missingAccessToken) + return + } + + continuation.resume(returning: accessToken) + } + } + } + + @MainActor + private func loginWithKakaoAccount() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + UserApi.shared.loginWithKakaoAccount { oauthToken, error in + if let error { + continuation.resume(throwing: KakaoLoginError.loginFailed(underlying: error)) + return + } + + guard let accessToken = oauthToken?.accessToken, !accessToken.isEmpty else { + continuation.resume(throwing: KakaoLoginError.missingAccessToken) + return + } + + continuation.resume(returning: accessToken) + } + } + } +} diff --git a/KillingPart/ViewModels/AppFlowViewModel.swift b/KillingPart/ViewModels/AppFlowViewModel.swift deleted file mode 100644 index d782a9a..0000000 --- a/KillingPart/ViewModels/AppFlowViewModel.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -@MainActor -final class AppFlowViewModel: ObservableObject { - @Published var currentStep: AppFlowStep = .splash - @Published var isLoading = false - @Published var loginErrorMessage: String? - - private let authenticationService: AuthenticationServicing - - init(authenticationService: AuthenticationServicing = AuthenticationService()) { - self.authenticationService = authenticationService - } - - func completeSplash() { - currentStep = .onboarding - } - - func completeOnboarding() { - currentStep = .login - } - - func login(email: String, password: String) { - guard !isLoading else { return } - - isLoading = true - loginErrorMessage = nil - - Task { - let isSuccess = await authenticationService.login(email: email, password: password) - isLoading = false - - if isSuccess { - currentStep = .main - } else { - loginErrorMessage = "이메일과 비밀번호를 입력해주세요." - } - } - } - - func logout() { - currentStep = .login - } -} diff --git a/KillingPart/ViewModels/AppViewModel.swift b/KillingPart/ViewModels/AppViewModel.swift new file mode 100644 index 0000000..6ee188f --- /dev/null +++ b/KillingPart/ViewModels/AppViewModel.swift @@ -0,0 +1,36 @@ +import Foundation + +@MainActor +final class AppViewModel: ObservableObject { + @Published var currentStep: AppFlowStep = .splash + + let loginViewModel: LoginViewModel + + init( + authenticationService: AuthenticationServicing = AuthenticationService(), + kakaoLoginService: KakaoLoginServicing = KakaoLoginService() + ) { + let loginViewModel = LoginViewModel( + authenticationService: authenticationService, + kakaoLoginService: kakaoLoginService + ) + + self.loginViewModel = loginViewModel + self.loginViewModel.onLoginSuccess = { [weak self] _ in + self?.currentStep = .main + } + } + + func completeSplash() { + currentStep = .onboarding + } + + func completeOnboarding() { + currentStep = .login + } + + func logout() { + loginViewModel.resetState() + currentStep = .login + } +} diff --git a/KillingPart/ViewModels/LoginViewModel.swift b/KillingPart/ViewModels/LoginViewModel.swift new file mode 100644 index 0000000..5d82196 --- /dev/null +++ b/KillingPart/ViewModels/LoginViewModel.swift @@ -0,0 +1,72 @@ +import Foundation + +@MainActor +final class LoginViewModel: ObservableObject { + @Published var isLoading = false + @Published var loginErrorMessage: String? + @Published private(set) var isNewUser = false + + var onLoginSuccess: ((Bool) -> Void)? + + private let authenticationService: AuthenticationServicing + private let kakaoLoginService: KakaoLoginServicing + + init( + authenticationService: AuthenticationServicing = AuthenticationService(), + kakaoLoginService: KakaoLoginServicing = KakaoLoginService(), + onLoginSuccess: ((Bool) -> Void)? = nil + ) { + self.authenticationService = authenticationService + self.kakaoLoginService = kakaoLoginService + self.onLoginSuccess = onLoginSuccess + } + + func login(email: String, password: String) { + guard !isLoading else { return } + + isLoading = true + loginErrorMessage = nil + + Task { + let isSuccess = await authenticationService.login(email: email, password: password) + isLoading = false + + if isSuccess { + isNewUser = false + onLoginSuccess?(false) + } else { + loginErrorMessage = "이메일과 비밀번호를 입력해주세요." + } + } + } + + func loginWithKakao() { + guard !isLoading else { return } + + isLoading = true + loginErrorMessage = nil + + Task { + defer { isLoading = false } + + do { + let kakaoAccessToken = try await kakaoLoginService.login() + let response = try await authenticationService.loginWithKakao(accessToken: kakaoAccessToken) + isNewUser = response.isNew + onLoginSuccess?(response.isNew) + } catch let authError as AuthenticationServiceError { + loginErrorMessage = authError.errorDescription + } catch let kakaoError as KakaoLoginError { + loginErrorMessage = kakaoError.errorDescription + } catch { + loginErrorMessage = "로그인 중 오류가 발생했어요. 다시 시도해 주세요." + } + } + } + + func resetState() { + isLoading = false + loginErrorMessage = nil + isNewUser = false + } +} diff --git a/KillingPart/Views/Screens/Auth/Components/KakaoLoginButton.swift b/KillingPart/Views/Screens/Auth/Components/KakaoLoginButton.swift new file mode 100644 index 0000000..e08d5ac --- /dev/null +++ b/KillingPart/Views/Screens/Auth/Components/KakaoLoginButton.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct KakaoLoginButton: View { + let isLoading: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + HStack(spacing: 4) { + Image("kakaoTalkBubble") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 19, height: 19) + .foregroundStyle(Color.black) + + Text("카카오 로그인") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.black.opacity(0.85)) + .lineLimit(1) + } + .padding(.horizontal, 20) + + if isLoading { + HStack { + Spacer() + ProgressView() + .tint(Color.black.opacity(0.85)) + .padding(.trailing, 20) + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 48) + .contentShape(RoundedRectangle(cornerRadius: 8)) + .background(Color(hex: "#FEE500")) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(isLoading) + } +} diff --git a/KillingPart/Views/Screens/Auth/Components/LoginBackgroundVideoView.swift b/KillingPart/Views/Screens/Auth/Components/LoginBackgroundVideoView.swift new file mode 100644 index 0000000..cd3acad --- /dev/null +++ b/KillingPart/Views/Screens/Auth/Components/LoginBackgroundVideoView.swift @@ -0,0 +1,140 @@ +import SwiftUI +import AVFoundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct LoginBackgroundVideoView: View { + @Environment(\.scenePhase) private var scenePhase + @StateObject private var videoPlayer = LoginBackgroundVideoPlayer() + + var body: some View { + Group { + if videoPlayer.isConfigured { + LoginVideoPlayerView(player: videoPlayer.player) + } else { + Color.black + } + } + .onAppear { + videoPlayer.play() + } + .onDisappear { + videoPlayer.pause() + } + .onChange(of: scenePhase) { phase in + if phase == .active { + videoPlayer.play() + } + } +#if canImport(UIKit) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + videoPlayer.play() + } +#endif + } +} + +@MainActor +private final class LoginBackgroundVideoPlayer: ObservableObject { + let player = AVPlayer() + let isConfigured: Bool + + private var endObserver: NSObjectProtocol? + + init() { + guard let videoURL = Bundle.main.url(forResource: "login", withExtension: "mp4") else { + isConfigured = false + return + } + + let item = AVPlayerItem(url: videoURL) + player.replaceCurrentItem(with: item) + player.isMuted = true + player.actionAtItemEnd = .none + + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak player] _ in + player?.seek(to: .zero) + player?.play() + } + + isConfigured = true + } + + deinit { + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + } + + func play() { + guard isConfigured else { return } + player.play() + } + + func pause() { + player.pause() + } +} + +#if canImport(UIKit) +private struct LoginVideoPlayerView: UIViewRepresentable { + let player: AVPlayer + + func makeUIView(context: Context) -> LoginPlayerUIView { + let view = LoginPlayerUIView() + view.playerLayer.videoGravity = .resizeAspectFill + view.playerLayer.player = player + return view + } + + func updateUIView(_ uiView: LoginPlayerUIView, context: Context) { + uiView.playerLayer.player = player + } +} + +private final class LoginPlayerUIView: UIView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + + var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } +} +#elseif canImport(AppKit) +private struct LoginVideoPlayerView: NSViewRepresentable { + let player: AVPlayer + + func makeNSView(context: Context) -> LoginPlayerNSView { + let view = LoginPlayerNSView() + view.playerLayer.videoGravity = .resizeAspectFill + view.playerLayer.player = player + return view + } + + func updateNSView(_ nsView: LoginPlayerNSView, context: Context) { + nsView.playerLayer.player = player + } +} + +private final class LoginPlayerNSView: NSView { + override func makeBackingLayer() -> CALayer { + AVPlayerLayer() + } + + var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + wantsLayer = true + } +} +#endif diff --git a/KillingPart/Views/Screens/Auth/LoginView.swift b/KillingPart/Views/Screens/Auth/LoginView.swift index 023b198..17817d2 100644 --- a/KillingPart/Views/Screens/Auth/LoginView.swift +++ b/KillingPart/Views/Screens/Auth/LoginView.swift @@ -1,46 +1,62 @@ import SwiftUI struct LoginView: View { - @ObservedObject var viewModel: AppFlowViewModel - - @State private var email = "" - @State private var password = "" + @ObservedObject var viewModel: LoginViewModel var body: some View { - VStack(alignment: .leading, spacing: AppSpacing.l) { - Spacer() - - Text("로그인") - .font(AppFont.paperlogy7Bold(size: 28)) - - VStack(spacing: AppSpacing.m) { - TextField("Email", text: $email) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - .padding(AppSpacing.m) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - - SecureField("Password", text: $password) - .padding(AppSpacing.m) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - if let message = viewModel.loginErrorMessage { - Text(message) - .font(.footnote) - .foregroundStyle(.red) - } - - PrimaryButton(title: "로그인", isLoading: viewModel.isLoading) { - viewModel.login(email: email, password: password) + GeometryReader { geometry in + let logoWidth = min(max(geometry.size.width * 0.75, 220), 560) + let horizontalPadding = max(AppSpacing.m, geometry.size.width * 0.06) + let topPadding = geometry.safeAreaInsets.top + AppSpacing.l + let bottomPadding = geometry.safeAreaInsets.bottom + AppSpacing.l + + ZStack { + LoginBackgroundVideoView() + .ignoresSafeArea() + + LinearGradient( + colors: [Color.black.opacity(0.15), Color.black.opacity(0.72)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + Image("loginTitle") + .resizable() + .scaledToFit() + .frame(width: logoWidth) + .frame(maxWidth: .infinity, alignment: .top) + .padding(.top, topPadding) + .padding(.horizontal, horizontalPadding) + + Spacer(minLength: AppSpacing.l) + + VStack(spacing: AppSpacing.m) { + Text("SNS로 간편로그인") + .font(AppFont.paperlogy5Medium(size: 15)) + .foregroundStyle(Color.kpGray300) + + if let message = viewModel.loginErrorMessage { + Text(message) + .font(.footnote) + .foregroundStyle(Color.red.opacity(0.95)) + } + + KakaoLoginButton( + isLoading: viewModel.isLoading, + action: viewModel.loginWithKakao + ) + } + .padding(.horizontal, horizontalPadding) + .padding(.bottom, bottomPadding) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - - Spacer() } - .padding(AppSpacing.l) - .background(AppColors.primary200.ignoresSafeArea()) } } + +#Preview { + LoginView(viewModel: LoginViewModel()) +} diff --git a/KillingPart/Views/Screens/RootFlowView.swift b/KillingPart/Views/Screens/RootFlowView.swift index d29d19e..bb04ba1 100644 --- a/KillingPart/Views/Screens/RootFlowView.swift +++ b/KillingPart/Views/Screens/RootFlowView.swift @@ -1,7 +1,7 @@ import SwiftUI struct RootFlowView: View { - @StateObject private var viewModel = AppFlowViewModel() + @StateObject private var viewModel = AppViewModel() var body: some View { Group { @@ -11,7 +11,7 @@ struct RootFlowView: View { case .onboarding: OnboardingContainerView(onContinue: viewModel.completeOnboarding) case .login: - LoginView(viewModel: viewModel) + LoginView(viewModel: viewModel.loginViewModel) case .main: MainTabView(onLogout: viewModel.logout) }