diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 7e99bf1a..4693653b 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine /// 앱의 최상위 RootView struct AppRootView: View { @@ -14,22 +15,97 @@ struct AppRootView: View { private let authDIContainer: AuthDIContainer private let appDIContainer: AppDIContainer + @State private var authRepository: AuthRepository + init(appDIContainer: AppDIContainer) { self._appRouter = StateObject(wrappedValue: appDIContainer.appRouter) self.authDIContainer = appDIContainer.makeAuthDIContainer() self.appDIContainer = appDIContainer + self._authRepository = State(wrappedValue: self.authDIContainer.authRepository) } var body: some View { - switch appRouter.currentAppState { - case .splash: - SplashContainerView(appRouter: appRouter) + ZStack { + Group { + switch appRouter.currentAppState { + case .splash: + SplashContainerView(appRouter: appRouter) + + case .auth: + authDIContainer.makeAuthFlowView() + + case .termsAgreement: + TermsAgreementView( + onComplete: { + appRouter.navigateToMain() + } + ) + + case .main: + MainTabView(appDIContainer: appDIContainer) + } + } + + // 로딩 오버레이 + if appRouter.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + } + } + .onOpenURL { url in + handleDeepLink(url: url) + } + } + + // MARK: - Deep Link Handler + private func handleDeepLink(url: URL) { + guard url.scheme == "codive", + url.host == "oauth", + url.path == "/callback" else { + print("Invalid deep link format: \(url)") + return + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + print("Failed to parse URL components or query items.") + return + } + + let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value + let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value + + guard let unwrappedAccessToken = accessToken, + let unwrappedRefreshToken = refreshToken else { + print("Access token or refresh token missing in deep link.") + // Potentially show an error to the user or log + return + } + + Task { + do { + try await authRepository.saveTokens(accessToken: unwrappedAccessToken, refreshToken: unwrappedRefreshToken) + + // 로딩 표시 + appRouter.showLoading() - case .auth: - authDIContainer.makeAuthFlowView() + // 회원 상태 확인 + let status = try await authRepository.checkAuthStatus() - case .main: - MainTabView(appDIContainer: appDIContainer) + switch status { + case .notAgreed: + appRouter.navigateToTerms() + case .registered: + appRouter.navigateToMain() + } + } catch { + appRouter.hideLoading() + // 실패 시 인증 화면으로 이동 + appRouter.finishSplash() + } } } } diff --git a/Codive/DIContainer/AuthDIContainer.swift b/Codive/DIContainer/AuthDIContainer.swift index bc5c8618..62880406 100644 --- a/Codive/DIContainer/AuthDIContainer.swift +++ b/Codive/DIContainer/AuthDIContainer.swift @@ -17,10 +17,13 @@ final class AuthDIContainer { // MARK: - Services (Data Layer) lazy var socialAuthService: SocialAuthServiceProtocol = SocialAuthService() + lazy var authAPIService: AuthAPIServiceProtocol = AuthAPIService() // MARK: - Repositories (Domain Layer) lazy var authRepository: AuthRepository = AuthRepositoryImpl( - socialAuthService: socialAuthService + socialAuthService: socialAuthService, + authAPIService: authAPIService, + keychainTokenProvider: KeychainTokenProvider() ) // MARK: - Initializer diff --git a/Codive/DIContainer/ClosetDIContainer.swift b/Codive/DIContainer/ClosetDIContainer.swift index 50bbeee1..913df8ce 100644 --- a/Codive/DIContainer/ClosetDIContainer.swift +++ b/Codive/DIContainer/ClosetDIContainer.swift @@ -20,9 +20,14 @@ final class ClosetDIContainer { self.navigationRouter = navigationRouter } + // MARK: - Services + private lazy var clothAPIService: ClothAPIServiceProtocol = { + return ClothAPIService() + }() + // MARK: - DataSources private lazy var clothDataSource: ClothDataSource = { - return DefaultClothDataSource() + return DefaultClothDataSource(apiService: clothAPIService) }() // MARK: - Repositories @@ -67,14 +72,16 @@ final class ClosetDIContainer { return ClothDetailViewModel( cloth: cloth, navigationRouter: navigationRouter, - deleteClothItemsUseCase: makeDeleteClothItemsUseCase() + deleteClothItemsUseCase: makeDeleteClothItemsUseCase(), + clothRepository: clothRepository ) } func makeClothEditViewModel(cloth: Cloth) -> ClothEditViewModel { return ClothEditViewModel( cloth: cloth, - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + clothRepository: clothRepository ) } diff --git a/Codive/Features/Auth/Data/AuthAPIService.swift b/Codive/Features/Auth/Data/AuthAPIService.swift new file mode 100644 index 00000000..ac81fcd9 --- /dev/null +++ b/Codive/Features/Auth/Data/AuthAPIService.swift @@ -0,0 +1,126 @@ +// +// AuthAPIService.swift +// Codive +// +// Created by 황상환 on 1/4/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Auth API Service Protocol +protocol AuthAPIServiceProtocol { + func checkAuthStatus() async throws -> RegisterStatus + func reissueTokens(refreshToken: String) async throws -> TokenPair + func renewDeviceToken(deviceToken: String) async throws +} + +// MARK: - Token Pair +struct TokenPair { + let accessToken: String + let refreshToken: String +} + +// MARK: - Auth API Service Implementation +final class AuthAPIService: AuthAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + func checkAuthStatus() async throws -> RegisterStatus { + let response = try await client.Auth_getUserStatus( + Operations.Auth_getUserStatus.Input() + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseUserStatusResponse.self, + from: data + ) + + guard let result = apiResponse.result, + let registerStatus = result.registerStatus else { + throw AuthError.networkError("회원 상태 정보 없음") + } + + switch registerStatus { + case .NOT_AGREED: + return .notAgreed + case .REGISTERED: + return .registered + } + + case .undocumented(statusCode: let statusCode, _): + throw AuthError.networkError("예상치 못한 응답 코드: \(statusCode)") + } + } + + // MARK: - Token Reissue + + func reissueTokens(refreshToken: String) async throws -> TokenPair { + let requestBody = Components.Schemas.TokenReissueRequest(refreshToken: refreshToken) + let input = Operations.Auth_reissueTokens.Input(body: .json(requestBody)) + let response = try await client.Auth_reissueTokens(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode(TokenReissueResponse.self, from: data) + + guard let result = apiResponse.result, + let accessToken = result.accessToken, + let newRefreshToken = result.refreshToken else { + throw AuthError.networkError("토큰 재발급 응답 파싱 실패") + } + + return TokenPair(accessToken: accessToken, refreshToken: newRefreshToken) + + case .undocumented(statusCode: let statusCode, _): + throw AuthError.networkError("토큰 재발급 실패: \(statusCode)") + } + } + + // MARK: - Device Token + + func renewDeviceToken(deviceToken: String) async throws { + let requestBody = Components.Schemas.DeviceTokenRenewRequest(deviceToken: deviceToken) + let input = Operations.Auth_renewDeviceToken.Input(body: .json(requestBody)) + let response = try await client.Auth_renewDeviceToken(input) + + switch response { + case .ok: + return + + case .undocumented(statusCode: let statusCode, _): + throw AuthError.networkError("디바이스 토큰 갱신 실패: \(statusCode)") + } + } +} + +// MARK: - Custom Response Types + +private struct TokenReissueResponse: Decodable { + let isSuccess: Bool? + let code: String? + let message: String? + let result: TokenResult? + + struct TokenResult: Decodable { + let accessToken: String? + let refreshToken: String? + } +} diff --git a/Codive/Features/Auth/Data/KeychainTokenProvider.swift b/Codive/Features/Auth/Data/KeychainTokenProvider.swift new file mode 100644 index 00000000..790aa256 --- /dev/null +++ b/Codive/Features/Auth/Data/KeychainTokenProvider.swift @@ -0,0 +1,23 @@ +// +// KeychainTokenProvider.swift +// Codive +// +// Created by 황상환 on 1/4/26. +// + +import Foundation +import CodiveAPI + +// MARK: - Keychain Token Provider +final class KeychainTokenProvider: TokenProvider { + + func getValidToken() async -> String? { + do { + let token = try KeychainManager.shared.getAccessToken() + return token + } catch { + print("토큰 조회 실패: \(error)") + return nil + } + } +} diff --git a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift index 4919ebe7..11e89e43 100644 --- a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift +++ b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift @@ -6,17 +6,26 @@ // import Foundation +import CodiveAPI // MARK: - Auth Repository Implementation @MainActor final class AuthRepositoryImpl: AuthRepository { - + // MARK: - Properties private let socialAuthService: SocialAuthServiceProtocol - + private let authAPIService: AuthAPIServiceProtocol + private let keychainTokenProvider: TokenProvider + // MARK: - Initializer - init(socialAuthService: SocialAuthServiceProtocol) { + init( + socialAuthService: SocialAuthServiceProtocol, + authAPIService: AuthAPIServiceProtocol = AuthAPIService(), + keychainTokenProvider: TokenProvider = KeychainTokenProvider() + ) { self.socialAuthService = socialAuthService + self.authAPIService = authAPIService + self.keychainTokenProvider = keychainTokenProvider } // MARK: - AuthRepository Implementation @@ -27,20 +36,18 @@ final class AuthRepositoryImpl: AuthRepository { case .apple: return await socialAuthService.appleLogin() } - - // 향후 서버 연결 시 확장될 로직: - // 1. 소셜 로그인 성공 - // 2. 서버에 사용자 정보 전송 - // 3. JWT 토큰 받아서 로컬 저장 - // 4. 최종 AuthResult 반환 } + func checkAuthStatus() async throws -> RegisterStatus { + return try await authAPIService.checkAuthStatus() + } + func logout() async { await socialAuthService.logout() - - // 향후 서버 연결 시 추가될 로직: - // 1. 서버에 로그아웃 요청 - // 2. 로컬 토큰 삭제 - // 3. 소셜 플랫폼 로그아웃 + } + + func saveTokens(accessToken: String, refreshToken: String) async throws { + try KeychainManager.shared.saveAccessToken(accessToken) + try KeychainManager.shared.saveRefreshToken(refreshToken) } } diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index e0ebcce7..1c4bccab 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -10,6 +10,7 @@ import KakaoSDKUser import KakaoSDKAuth import KakaoSDKCommon import AuthenticationServices +import UIKit // MARK: - Social Auth Service Protocol protocol SocialAuthServiceProtocol { @@ -24,70 +25,76 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { private var appleContinuation: CheckedContinuation? - // MARK: - Kakao Login + // MARK: - Kakao Login (OIDC via ASWebAuthenticationSession) func kakaoLogin() async -> AuthResult { + guard let urlString = Bundle.main.object(forInfoDictionaryKey: "KAKAO_AUTH_URL") as? String, + let authURL = URL(string: urlString) else { + return .failure(.unknown("Invalid auth URL configuration")) + } + return await withCheckedContinuation { continuation in - if UserApi.isKakaoTalkLoginAvailable() { - UserApi.shared.loginWithKakaoTalk { _, error in - if let error = error { - self.handleKakaoError(error, continuation: continuation) + let session = ASWebAuthenticationSession( + url: authURL, + callbackURLScheme: "codive" + ) { callbackURL, error in + // 에러 처리 + if let error = error { + if let authError = error as? ASWebAuthenticationSessionError { + switch authError.code { + case .canceledLogin: + continuation.resume(returning: .failure(.cancelled)) + default: + continuation.resume(returning: .failure(.networkError(error.localizedDescription))) + } } else { - self.fetchKakaoUserInfo(continuation: continuation) + continuation.resume(returning: .failure(.networkError(error.localizedDescription))) } + return } - } else { - UserApi.shared.loginWithKakaoAccount { _, error in - if let error = error { - self.handleKakaoError(error, continuation: continuation) - } else { - self.fetchKakaoUserInfo(continuation: continuation) - } + + // 콜백 URL에서 토큰 파싱 + guard let callbackURL = callbackURL else { + continuation.resume(returning: .failure(.tokenParsingError)) + return + } + + // URL 파라미터에서 토큰 추출 + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + continuation.resume(returning: .failure(.tokenParsingError)) + return + } + + let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value + let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value + + guard let accessToken = accessToken, + let refreshToken = refreshToken else { + continuation.resume(returning: .failure(.tokenParsingError)) + return + } + + // Keychain에 토큰 저장 + do { + try KeychainManager.shared.saveAccessToken(accessToken) + try KeychainManager.shared.saveRefreshToken(refreshToken) + + // 성공 시 임시 사용자 정보 반환 (나중에 서버에서 받아야 함) + let authUser = AuthUser( + id: "temp_kakao_user", + email: nil, + name: nil, + provider: .kakao + ) + continuation.resume(returning: .success(authUser)) + } catch { + continuation.resume(returning: .failure(.keychainError(error.localizedDescription))) } } - } - } - - private func fetchKakaoUserInfo(continuation: CheckedContinuation) { - UserApi.shared.me { user, error in - if error != nil { - continuation.resume(returning: .failure(.userInfoError)) - } else if let user = user { - let authUser = AuthUser( - id: "\(user.id ?? 0)", - email: user.kakaoAccount?.email, - name: user.kakaoAccount?.profile?.nickname, - provider: .kakao - ) - continuation.resume(returning: .success(authUser)) - } else { - continuation.resume(returning: .failure(.userInfoError)) - } - } - } - - private func handleKakaoError(_ error: Error, continuation: CheckedContinuation) { - if let sdkError = error as? SdkError { - switch sdkError { - case .ClientFailed(reason: .Cancelled, _): - continuation.resume(returning: .failure(.cancelled)) - return - default: - break - } - } - - let errorMessage = error.localizedDescription.lowercased() - - if errorMessage.contains("cancelled") || - errorMessage.contains("cancel") || - errorMessage.contains("user_cancelled") || - errorMessage.contains("취소") || - errorMessage.contains("the operation couldn't be completed") || - errorMessage.contains("sdkerror error 0") { - continuation.resume(returning: .failure(.cancelled)) - } else { - print("카카오 로그인 에러: \(error.localizedDescription)") - continuation.resume(returning: .failure(.networkError(error.localizedDescription))) + + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + session.start() } } @@ -123,15 +130,15 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { // MARK: - Apple Sign In Delegate extension SocialAuthService: ASAuthorizationControllerDelegate { - + func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { defer { appleContinuation = nil } - + guard let appleContinuation = appleContinuation else { return } - + if let credential = authorization.credential as? ASAuthorizationAppleIDCredential { let authUser = AuthUser( id: credential.user, @@ -144,15 +151,15 @@ extension SocialAuthService: ASAuthorizationControllerDelegate { appleContinuation.resume(returning: .failure(.userInfoError)) } } - + func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error ) { defer { appleContinuation = nil } - + guard let appleContinuation = appleContinuation else { return } - + if let authError = error as? ASAuthorizationError { switch authError.code { case .canceled: @@ -165,3 +172,15 @@ extension SocialAuthService: ASAuthorizationControllerDelegate { } } } + +// MARK: - ASWebAuthenticationPresentationContextProviding +extension SocialAuthService: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } ?? UIWindow() + + return window + } +} diff --git a/Codive/Features/Auth/Data/TermsAPIService.swift b/Codive/Features/Auth/Data/TermsAPIService.swift new file mode 100644 index 00000000..dec3c3bd --- /dev/null +++ b/Codive/Features/Auth/Data/TermsAPIService.swift @@ -0,0 +1,134 @@ +// +// TermsAPIService.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Terms API Service Protocol + +protocol TermsAPIServiceProtocol { + func fetchTerms() async throws -> [TermItem] + func agreeTerms(agreements: [TermAgreement]) async throws +} + +// MARK: - Supporting Types + +struct TermItem { + let termId: Int64 + let title: String + let isOptional: Bool +} + +struct TermAgreement { + let termId: Int64 + let agreed: Bool +} + +// MARK: - Custom Response Types (서버 응답 디코딩용) + +private struct TermsResponse: Decodable { + let isSuccess: Bool? + let code: String? + let message: String? + let result: TermsResult? +} + +private struct TermsResult: Decodable { + let payloads: [TermPayload]? +} + +private struct TermPayload: Decodable { + let termId: Int64? + let title: String? + let body: String? + let optional: Bool? +} + +// MARK: - Terms API Service Implementation + +final class TermsAPIService: TermsAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + // MARK: - GET /terms + + func fetchTerms() async throws -> [TermItem] { + let input = Operations.Term_getTerms.Input() + let response = try await client.Term_getTerms(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + // 커스텀 타입으로 디코딩 (서버 응답에 title, optional 필드 있음) + let decoded = try jsonDecoder.decode(TermsResponse.self, from: data) + + guard let payloads = decoded.result?.payloads else { + throw TermsAPIError.noData + } + + return payloads.compactMap { payload in + guard let termId = payload.termId else { return nil } + return TermItem( + termId: termId, + title: payload.title ?? "", + isOptional: payload.optional ?? false + ) + } + + case .undocumented(statusCode: let code, _): + throw TermsAPIError.serverError(statusCode: code) + } + } + + // MARK: - POST /terms + + func agreeTerms(agreements: [TermAgreement]) async throws { + let payloads = agreements.map { agreement in + Components.Schemas.Payload( + termId: agreement.termId, + agreed: agreement.agreed + ) + } + + let requestBody = Components.Schemas.TermAgreeRequest(payloads: payloads) + let input = Operations.Term_agreeTerm.Input(body: .json(requestBody)) + let response = try await client.Term_agreeTerm(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw TermsAPIError.serverError(statusCode: code) + } + } +} + +// MARK: - TermsAPIError + +enum TermsAPIError: LocalizedError { + case noData + case serverError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .noData: + return "약관 데이터가 없습니다." + case .serverError(let code): + return "서버 오류 (\(code))" + } + } +} diff --git a/Codive/Features/Auth/Data/TokenService.swift b/Codive/Features/Auth/Data/TokenService.swift new file mode 100644 index 00000000..3e61857b --- /dev/null +++ b/Codive/Features/Auth/Data/TokenService.swift @@ -0,0 +1,116 @@ +// +// TokenService.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation + +// MARK: - Token Config + +enum TokenConfig { + /// 토큰 만료 여유 시간 (만료 전 이 시간부터 만료로 간주) + static let expirationBuffer: TimeInterval = 300 // 5분 +} + +// MARK: - Token Service Protocol + +protocol TokenServiceProtocol { + func isAccessTokenExpired() -> Bool + func isRefreshTokenExpired() -> Bool + func hasValidTokens() -> Bool + func getAccessToken() -> String? + func getRefreshToken() -> String? +} + +// MARK: - Token Service Implementation + +final class TokenService: TokenServiceProtocol { + + private let keychainManager: KeychainManager + + init(keychainManager: KeychainManager = KeychainManager.shared) { + self.keychainManager = keychainManager + } + + // MARK: - Public Methods + + /// 키체인에 유효한 토큰이 있는지 확인 + func hasValidTokens() -> Bool { + guard let _ = getAccessToken(), let _ = getRefreshToken() else { + return false + } + return true + } + + /// Access Token 만료 여부 확인 + func isAccessTokenExpired() -> Bool { + guard let token = getAccessToken() else { return true } + return isTokenExpired(token) + } + + /// Refresh Token 만료 여부 확인 + func isRefreshTokenExpired() -> Bool { + guard let token = getRefreshToken() else { return true } + return isTokenExpired(token) + } + + /// Access Token 가져오기 + func getAccessToken() -> String? { + return try? keychainManager.getAccessToken() + } + + /// Refresh Token 가져오기 + func getRefreshToken() -> String? { + return try? keychainManager.getRefreshToken() + } + + // MARK: - Private Methods + + /// JWT 토큰 만료 여부 확인 + private func isTokenExpired(_ token: String) -> Bool { + guard let exp = extractExpiration(from: token) else { + // 파싱 실패 시 만료로 간주 + return true + } + + let currentTime = Date().timeIntervalSince1970 + return currentTime > (exp - TokenConfig.expirationBuffer) + } + + /// JWT에서 만료 시간(exp) 추출 + private func extractExpiration(from token: String) -> TimeInterval? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + + let payloadPart = String(parts[1]) + + // Base64 URL Safe → 일반 Base64로 변환 + 패딩 추가 + guard let payloadData = base64URLDecode(payloadPart) else { + return nil + } + + guard let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let exp = payload["exp"] as? TimeInterval else { + return nil + } + + return exp + } + + /// Base64 URL Safe 디코딩 + private func base64URLDecode(_ string: String) -> Data? { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + // 패딩 추가 + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + + return Data(base64Encoded: base64) + } +} diff --git a/Codive/Features/Auth/Domain/Models/AuthModels.swift b/Codive/Features/Auth/Domain/Models/AuthModels.swift index e649cd56..13b73f6f 100644 --- a/Codive/Features/Auth/Domain/Models/AuthModels.swift +++ b/Codive/Features/Auth/Domain/Models/AuthModels.swift @@ -42,7 +42,9 @@ enum AuthError: Error, LocalizedError { case networkError(String) case userInfoError case unknown(String) - + case tokenParsingError + case keychainError(String) + var errorDescription: String? { switch self { case .cancelled: @@ -53,6 +55,22 @@ enum AuthError: Error, LocalizedError { return "사용자 정보를 가져오는데 실패했습니다" case .unknown(let message): return "알 수 없는 오류: \(message)" + case .tokenParsingError: + return "토큰 파싱 오류" + case .keychainError(let message): + return "토큰 저장 오류: \(message)" } } } + +// MARK: - Auth Token Response (서버 응답) +struct AuthTokenResponse: Decodable { + let accessToken: String + let refreshToken: String +} + +// MARK: - Register Status +enum RegisterStatus { + case notAgreed // 약관 동의 필요 + case registered // 약관 동의 완료 +} diff --git a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift index 66c8cc31..32760919 100644 --- a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift +++ b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift @@ -10,9 +10,7 @@ import Foundation // MARK: - Auth Repository Protocol protocol AuthRepository { func socialLogin(provider: AuthProvider) async -> AuthResult + func checkAuthStatus() async throws -> RegisterStatus func logout() async - - // 향후 서버 연결 시 추가될 메서드들 - // func refreshToken() async -> AuthResult - // func deleteAccount() async -> Bool + func saveTokens(accessToken: String, refreshToken: String) async throws } diff --git a/Codive/Features/Auth/Presentation/View/OnboardingView.swift b/Codive/Features/Auth/Presentation/View/OnboardingView.swift index 078cd838..b4fd0ca4 100644 --- a/Codive/Features/Auth/Presentation/View/OnboardingView.swift +++ b/Codive/Features/Auth/Presentation/View/OnboardingView.swift @@ -7,6 +7,12 @@ import SwiftUI +// MARK: - Identifiable URL Wrapper +struct IdentifiableURL: Identifiable { + let id = UUID() + let url: URL +} + // MARK: - Onboarding Data Model struct OnboardingPage: Identifiable { let id = UUID() @@ -183,6 +189,10 @@ struct OnboardingContainerView: View { errorMessage: viewModel.errorMessage, onErrorDismiss: viewModel.clearError ) + .sheet(item: $viewModel.identifiableLoginURL) { item in + SafariView(url: item.url) + .ignoresSafeArea() + } } } diff --git a/Codive/Features/Auth/Presentation/View/SplashView.swift b/Codive/Features/Auth/Presentation/View/SplashView.swift index 699d3f12..1e5fa624 100644 --- a/Codive/Features/Auth/Presentation/View/SplashView.swift +++ b/Codive/Features/Auth/Presentation/View/SplashView.swift @@ -8,6 +8,7 @@ import SwiftUI // MARK: - SplashView (순수 UI) + struct SplashView: View { let displayedText: String @@ -44,7 +45,8 @@ struct SplashView: View { } } -// MARK: - SplashContainerView +// MARK: - SplashContainerView + struct SplashContainerView: View { @StateObject private var viewModel: SplashViewModel @@ -61,45 +63,8 @@ struct SplashContainerView: View { } } -// MARK: - SplashViewModel (타이핑 애니메이션 로직) -@MainActor -final class SplashViewModel: ObservableObject { - - @Published var displayedText: String = "" - - private let fullText: String = "Codive" - private let typingSpeed: Double = 0.4 - private let appRouter: AppRouter - - init(appRouter: AppRouter) { - self.appRouter = appRouter - } - - func startAnimation() async { - // 타이핑 애니메이션 - for i in 1...fullText.count { - let endIndex = fullText.index(fullText.startIndex, offsetBy: i) - displayedText = String(fullText[.. Void + + // API Service + private let termsAPIService: TermsAPIServiceProtocol + + // 약관 상태 관리 (termId 매핑) + @State private var agreements: [Int64: Bool] = [:] - // 약관 상태 관리 - @State private var isServiceAgreed = false - @State private var isPrivacyAgreed = false - @State private var isLocationAgreed = false - @State private var isMarketingAgreed = false - - // 전체 동의 계산 프로퍼티 + // 서버에서 받아온 약관 목록 + @State private var termsList: [TermItem] = [] + + // 로딩 상태 + @State private var isLoading = false + @State private var errorMessage: String? + + // 전체 동의 여부 private var isAllAgreed: Bool { - get { - isServiceAgreed && isPrivacyAgreed && isLocationAgreed && isMarketingAgreed - } - set { - isServiceAgreed = newValue - isPrivacyAgreed = newValue - isLocationAgreed = newValue - isMarketingAgreed = newValue - } + guard !termsList.isEmpty else { return false } + return agreements.values.allSatisfy { $0 } && agreements.count == termsList.count } - - // 필수 항목 동의 여부 확인 + + // 필수 항목 동의 여부 private var canProceed: Bool { - isServiceAgreed && isPrivacyAgreed && isLocationAgreed + let requiredTerms = termsList.filter { !$0.isOptional } + return requiredTerms.allSatisfy { agreements[$0.termId] == true } } - + + init( + onComplete: @escaping () -> Void, + termsAPIService: TermsAPIServiceProtocol = TermsAPIService() + ) { + self.onComplete = onComplete + self.termsAPIService = termsAPIService + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - // 상단 뒤로가기 버튼 - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .font(.codive_title1) - .foregroundColor(.Codive.main1) - } - .padding(.top, 10) - .padding(.horizontal, 20) - // 타이틀 Text("약관에 동의하시면\n회원가입이 완료됩니다.") .font(.codive_title1) .lineSpacing(6) .padding(.top, 30) .padding(.horizontal, 20) - + Spacer() - // 약관 리스트 섹션 - VStack(spacing: 0) { - // 전체 동의 - AgreementRow( - title: "전체 동의", - isAgreed: Binding( - get: { self.isAllAgreed }, - set: { newValue in - self.isServiceAgreed = newValue - self.isPrivacyAgreed = newValue - self.isLocationAgreed = newValue - self.isMarketingAgreed = newValue - } - ), - isBold: true, - showChevron: false - ) - - Divider() - .background(Color.Codive.grayscale2) - .padding(.vertical, 10) - - // 개별 항목들 - AgreementRow(title: "서비스 이용약관", isAgreed: $isServiceAgreed, isRequired: true) - AgreementRow(title: "개인정보 수집/이용 동의", isAgreed: $isPrivacyAgreed, isRequired: true) - AgreementRow(title: "위치 기반 서비스 이용약관 동의", isAgreed: $isLocationAgreed, isRequired: true) - AgreementRow(title: "마케팅 정보수신 동의", isAgreed: $isMarketingAgreed, isRequired: false) + if isLoading && termsList.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + } else { + // 약관 리스트 섹션 + VStack(spacing: 0) { + // 전체 동의 + AgreementRow( + title: "전체 동의", + isAgreed: Binding( + get: { isAllAgreed }, + set: { newValue in + for term in termsList { + agreements[term.termId] = newValue + } + } + ), + isBold: true, + showChevron: false + ) + + Divider() + .background(Color.Codive.grayscale2) + .padding(.vertical, 10) + + // 개별 항목들 + ForEach(termsList, id: \.termId) { term in + AgreementRow( + title: term.title, + isAgreed: binding(for: term.termId), + isRequired: !term.isOptional + ) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 50) } - .padding(.horizontal, 20) - .padding(.bottom, 50) - + + // 에러 메시지 + if let errorMessage = errorMessage { + Text(errorMessage) + .font(.codive_body3_regular) + .foregroundColor(.red) + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + // 가입 완료 버튼 - CustomButton(text: "가입 완료", widthType: .fixed, isEnabled: canProceed) { - // 회원가입 완료 로직 + CustomButton( + text: isLoading && !termsList.isEmpty ? "처리 중..." : "가입 완료", + widthType: .fixed, + isEnabled: canProceed && (!isLoading || termsList.isEmpty) + ) { + submitAgreements() } .frame(height: 56) .padding(.horizontal, 20) .padding(.bottom, 10) } .navigationBarHidden(true) + .task { + await loadTerms() + } + } + + // MARK: - Helper Methods + + private func loadTerms() async { + isLoading = true + errorMessage = nil + + do { + let fetchedTerms = try await termsAPIService.fetchTerms() + termsList = fetchedTerms + + // agreements 초기화 + for term in fetchedTerms { + if agreements[term.termId] == nil { + agreements[term.termId] = false + } + } + } catch { + errorMessage = "약관 정보를 불러오는데 실패했습니다: \(error.localizedDescription)" + } + + isLoading = false + } + + private func binding(for termId: Int64) -> Binding { + Binding( + get: { agreements[termId] ?? false }, + set: { agreements[termId] = $0 } + ) + } + + private func submitAgreements() { + isLoading = true + errorMessage = nil + + Task { + do { + // 동의한 항목만 필터링하거나 전체 전송 (API 명세에 따름) + // 여기서는 체크된 항목들의 리스트를 전송 + let termAgreements = agreements.map { termId, agreed in + TermAgreement(termId: termId, agreed: agreed) + } + + // POST /terms 호출 + try await termsAPIService.agreeTerms(agreements: termAgreements) + + // 메인으로 이동 + await MainActor.run { + isLoading = false + onComplete() + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = "약관 동의에 실패했습니다: \(error.localizedDescription)" + } + } + } } } @@ -104,7 +189,7 @@ struct AgreementRow: View { var isBold: Bool = false var isRequired: Bool? = nil var showChevron: Bool = true - + var body: some View { HStack(spacing: 12) { // 체크박스 @@ -113,7 +198,7 @@ struct AgreementRow: View { .font(.codive_title1) .foregroundColor(isAgreed ? .Codive.point1 : .Codive.point4) } - + // 제목 (필수/선택 강조 포함) HStack(spacing: 4) { if let isRequired = isRequired { @@ -124,9 +209,9 @@ struct AgreementRow: View { } .font(isBold ? .codive_body1_bold : .codive_body1_regular) .foregroundColor(isBold ? .Codive.grayscale1 : .Codive.grayscale4) - + Spacer() - + // 상세 보기 버튼 if showChevron { Button(action: { /* 상세 페이지 이동 */ }) { @@ -141,5 +226,5 @@ struct AgreementRow: View { } #Preview { - TermsAgreementView() + TermsAgreementView(onComplete: {}) } diff --git a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift index f08210c9..fb3e4bad 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift @@ -18,6 +18,7 @@ final class OnboardingViewModel: ObservableObject { // MARK: - Published Properties @Published var isLoading = false @Published var errorMessage: String? + @Published var identifiableLoginURL: IdentifiableURL? // MARK: - Initializer init( @@ -35,7 +36,7 @@ final class OnboardingViewModel: ObservableObject { isLoading = true errorMessage = nil - let result = await authRepository.socialLogin(provider: .kakao) // Repository 사용 + let result = await authRepository.socialLogin(provider: .kakao) isLoading = false @@ -49,13 +50,6 @@ final class OnboardingViewModel: ObservableObject { case .cancelled: print("카카오 로그인 취소됨") return - case .networkError(let message): - if message.contains("The operation couldn't be completed") || - message.contains("KakaoSDKCommon.SdkError error 0") { - print("카카오 로그인 취소됨 (네트워크 에러로 분류된 취소)") - return - } - errorMessage = error.localizedDescription default: errorMessage = error.localizedDescription } @@ -66,7 +60,7 @@ final class OnboardingViewModel: ObservableObject { isLoading = true errorMessage = nil - let result = await authRepository.socialLogin(provider: .apple) // Repository 사용 + let result = await authRepository.socialLogin(provider: .apple) isLoading = false diff --git a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift new file mode 100644 index 00000000..e987e7c7 --- /dev/null +++ b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift @@ -0,0 +1,133 @@ +// +// SplashViewModel.swift +// Codive +// +// Created by 황상환 on 1/14/26. +// + +import Foundation + +// MARK: - SplashViewModel + +@MainActor +final class SplashViewModel: ObservableObject { + + // MARK: - Published Properties + + @Published var displayedText: String = "" + + // MARK: - Private Properties + + private let fullText: String = "Codive" + private let typingSpeed: Double = 0.4 + private let appRouter: AppRouter + + private let tokenService: TokenServiceProtocol + private let authAPIService: AuthAPIServiceProtocol + private let keychainManager: KeychainManager + + // MARK: - Initializer + + init( + appRouter: AppRouter, + tokenService: TokenServiceProtocol = TokenService(), + authAPIService: AuthAPIServiceProtocol = AuthAPIService(), + keychainManager: KeychainManager = KeychainManager.shared + ) { + self.appRouter = appRouter + self.tokenService = tokenService + self.authAPIService = authAPIService + self.keychainManager = keychainManager + } + + // MARK: - Public Methods + + func startAnimation() async { + await performTypingAnimation() + await checkAutoLogin() + } + + // MARK: - Private Methods + + private func performTypingAnimation() async { + for i in 1...fullText.count { + let endIndex = fullText.index(fullText.startIndex, offsetBy: i) + displayedText = String(fullText[.. [PresignedUrlInfo] + func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws + func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] + func fetchClothes(lastClothId: Int64?, size: Int32, categoryId: Int64?, seasons: [Season]) async throws -> ClothListResult + func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult + func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws + func deleteCloth(clothId: Int64) async throws +} + +// MARK: - Supporting Types + +struct PresignedUrlInfo { + let presignedUrl: String + let finalUrl: String + let md5Hash: String +} + +struct ClothCreateAPIRequest { + let clothImageUrl: String + let clothUrl: String? + let name: String? + let brand: String? + let seasons: [Season] + let categoryId: Int64 +} + +struct ClothListResult { + let clothes: [ClothListItem] + let isLast: Bool +} + +struct ClothListItem { + let clothId: Int64 + let imageUrl: String + let brand: String? + let name: String? +} + +struct ClothDetailResult { + let clothImageUrl: String + let parentCategory: String? + let category: String? + let name: String? + let brand: String? + let clothUrl: String? +} + +struct ClothUpdateAPIRequest { + let clothImageUrl: String? + let clothUrl: String? + let name: String? + let brand: String? + let seasons: [Season] // API에서 required + let categoryId: Int64 // API에서 required +} + +// MARK: - ClothAPIService Implementation + +final class ClothAPIService: ClothAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } +} + +// MARK: - Presigned URL & S3 Upload + +extension ClothAPIService { + + func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] { + let payloads = images.map { imageData in + let md5Hash = calculateMD5(from: imageData) + return ( + payload: Components.Schemas.ClothImagesUploadRequestPayload(fileExtension: .JPEG, md5Hashes: md5Hash), + md5Hash: md5Hash + ) + } + + let requestBody = Components.Schemas.ClothImagesUploadRequest(payloads: payloads.map { $0.payload }) + let input = Operations.Cloth_getClothUploadPresignedUrl.Input(body: .json(requestBody)) + let response = try await client.Cloth_getClothUploadPresignedUrl(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseClothImagesPresignedUrlResponse.self, from: data) + + guard let urls = decoded.result?.urls, urls.count == images.count else { + throw ClothAPIError.presignedUrlMismatch + } + + return zip(urls, payloads).map { url, payloadInfo in + PresignedUrlInfo(presignedUrl: url, finalUrl: extractFinalUrl(from: url), md5Hash: payloadInfo.md5Hash) + } + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "Presigned URL 발급 실패") + } + } + + func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws { + guard let url = URL(string: presignedUrl) else { + throw ClothAPIError.invalidUrl + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") + request.httpBody = imageData + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClothAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ClothAPIError.s3UploadFailed(statusCode: httpResponse.statusCode) + } + } +} + +// MARK: - Create Clothes + +extension ClothAPIService { + + func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] { + let apiRequests = requests.map { request in + Components.Schemas.ClothCreateRequest( + clothImageUrl: request.clothImageUrl, + clothUrl: request.clothUrl, + name: request.name, + brand: request.brand, + seasons: request.seasons.map { mapSeasonToCreatePayload($0) }, + categoryId: request.categoryId + ) + } + + let requestBody = Components.Schemas.ClothCreateRequests(content: apiRequests) + let input = Operations.Cloth_createClothes.Input(body: .json(requestBody)) + let response = try await client.Cloth_createClothes(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseClothCreateResponse.self, from: data) + + guard let clothIds = decoded.result?.clothIds else { + throw ClothAPIError.noClothIdsReturned + } + return clothIds + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 생성 실패") + } + } +} + +// MARK: - Fetch Clothes + +extension ClothAPIService { + + func fetchClothes(lastClothId: Int64?, size: Int32, categoryId: Int64?, seasons: [Season]) async throws -> ClothListResult { + let seasonsParam = seasons.isEmpty ? nil : seasons.map { mapSeasonToQueryParam($0) } + + let input = Operations.Cloth_getClothes.Input( + query: .init(lastClothId: lastClothId, size: size, direction: .DESC, categoryId: categoryId, seasons: seasonsParam) + ) + + let response = try await client.Cloth_getClothes(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseClothListResponse.self, from: data) + + let clothes: [ClothListItem] = decoded.result?.content?.map { item -> ClothListItem in + return ClothListItem(clothId: item.clothId ?? 0, imageUrl: item.ImageUrl ?? "", brand: item.brand, name: item.name) + } ?? [] + + return ClothListResult(clothes: clothes, isLast: decoded.result?.isLast ?? true) + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 목록 조회 실패") + } + } + + func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult { + let input = Operations.Cloth_getClothDetails.Input(path: .init(clothId: clothId)) + let response = try await client.Cloth_getClothDetails(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseClothDetailsResponse.self, from: data) + + guard let result = decoded.result else { + throw ClothAPIError.serverError(statusCode: 0, message: "result가 nil입니다") + } + + return ClothDetailResult( + clothImageUrl: result.clothImageUrl ?? "", + parentCategory: result.parentCategory, + category: result.category, + name: result.name, + brand: result.brand, + clothUrl: result.clothUrl + ) + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 상세 조회 실패") + } + } +} + +// MARK: - Update & Delete + +extension ClothAPIService { + + func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws { + let requestBody = Components.Schemas.ClothUpdateRequest( + clothImageUrl: request.clothImageUrl, + clothUrl: request.clothUrl, + name: request.name, + brand: request.brand, + seasons: request.seasons.map { mapSeasonToUpdatePayload($0) }, + categoryId: request.categoryId + ) + + let input = Operations.Cloth_updateCloth.Input(path: .init(clothId: clothId), body: .json(requestBody)) + let response = try await client.Cloth_updateCloth(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 수정 실패") + } + } + + func deleteCloth(clothId: Int64) async throws { + let input = Operations.Cloth_deleteCloth.Input(path: .init(clothId: clothId)) + let response = try await client.Cloth_deleteCloth(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 삭제 실패") + } + } +} + +// MARK: - Private Helpers + +private extension ClothAPIService { + + func calculateMD5(from data: Data) -> String { + let digest = Insecure.MD5.hash(data: data) + return Data(digest).base64EncodedString() + } + + func extractFinalUrl(from presignedUrl: String) -> String { + guard let url = URL(string: presignedUrl), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return presignedUrl + } + components.query = nil + return components.string ?? presignedUrl + } + + func mapSeasonToCreatePayload(_ season: Season) -> Components.Schemas.ClothCreateRequest.seasonsPayloadPayload { + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER + } + } + + func mapSeasonToUpdatePayload(_ season: Season) -> Components.Schemas.ClothUpdateRequest.seasonsPayloadPayload { + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER + } + } + + func mapSeasonToQueryParam(_ season: Season) -> Operations.Cloth_getClothes.Input.Query.seasonsPayloadPayload { + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER + } + } +} + +// MARK: - ClothAPIError + +enum ClothAPIError: LocalizedError { + case presignedUrlMismatch + case invalidUrl + case invalidResponse + case s3UploadFailed(statusCode: Int) + case noClothIdsReturned + case serverError(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .presignedUrlMismatch: + return "Presigned URL 개수가 요청한 이미지 개수와 일치하지 않습니다." + case .invalidUrl: + return "유효하지 않은 URL입니다." + case .invalidResponse: + return "서버 응답을 처리할 수 없습니다." + case .s3UploadFailed(let statusCode): + return "S3 업로드 실패 (상태 코드: \(statusCode))" + case .noClothIdsReturned: + return "서버에서 생성된 옷 ID를 반환하지 않았습니다." + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + } + } +} diff --git a/Codive/Features/Closet/Data/Constants/CategoryConstants.swift b/Codive/Features/Closet/Data/Constants/CategoryConstants.swift index 031f9f45..68925995 100644 --- a/Codive/Features/Closet/Data/Constants/CategoryConstants.swift +++ b/Codive/Features/Closet/Data/Constants/CategoryConstants.swift @@ -13,49 +13,132 @@ struct CategoryConstants { CategoryItem( id: 1, name: "상의", - subcategories: ["티셔츠", "니트/스웨터", "맨투맨", "후드티", "반팔티", "셔츠/블라우스", "나시", "기타"] + subcategories: [ + SubcategoryItem(id: 8, name: "티셔츠"), + SubcategoryItem(id: 9, name: "니트/스웨터"), + SubcategoryItem(id: 10, name: "맨투맨"), + SubcategoryItem(id: 11, name: "후드티"), + SubcategoryItem(id: 12, name: "셔츠/블라우스"), + SubcategoryItem(id: 13, name: "반팔티"), + SubcategoryItem(id: 14, name: "나시"), + SubcategoryItem(id: 15, name: "기타") + ] ), CategoryItem( id: 2, name: "바지", - subcategories: ["청바지", "반바지", "트레이닝/조거팬츠", "슈트팬츠/슬랙스", "레깅스", "기타"] + subcategories: [ + SubcategoryItem(id: 16, name: "청바지"), + SubcategoryItem(id: 17, name: "반바지"), + SubcategoryItem(id: 18, name: "트레이닝/조거팬츠"), + SubcategoryItem(id: 19, name: "면바지"), + SubcategoryItem(id: 20, name: "슈트팬츠/슬랙스"), + SubcategoryItem(id: 21, name: "레깅스"), + SubcategoryItem(id: 22, name: "기타") + ] ), CategoryItem( id: 3, - name: "치마", - subcategories: ["미니스커트", "미디스커트", "롱스커트", "원피스", "투피스", "기타"] + name: "스커트", + subcategories: [ + SubcategoryItem(id: 23, name: "미니스커트"), + SubcategoryItem(id: 24, name: "미디스커트"), + SubcategoryItem(id: 25, name: "롱스커트"), + SubcategoryItem(id: 26, name: "원피스"), + SubcategoryItem(id: 27, name: "투피스"), + SubcategoryItem(id: 28, name: "기타") + ] ), CategoryItem( id: 4, name: "아우터", - subcategories: ["숏패딩/헤비 아우터", "무스탕/퍼", "후드집업", "점퍼/바람막이", "가죽자켓", "청자켓", "슈트/블레이져", "가디건", "아노락", "후리스/양털", "코트", "롱패딩", "패딩조끼", "기타"] + subcategories: [ + SubcategoryItem(id: 29, name: "숏패딩/헤비 아우터"), + SubcategoryItem(id: 30, name: "무스탕/퍼"), + SubcategoryItem(id: 31, name: "후드집업"), + SubcategoryItem(id: 32, name: "점퍼/바람막이"), + SubcategoryItem(id: 33, name: "가죽자켓"), + SubcategoryItem(id: 34, name: "청자켓"), + SubcategoryItem(id: 35, name: "슈트/블레이저"), + SubcategoryItem(id: 36, name: "가디건"), + SubcategoryItem(id: 37, name: "아노락"), + SubcategoryItem(id: 38, name: "후리스/양털"), + SubcategoryItem(id: 39, name: "코트"), + SubcategoryItem(id: 40, name: "롱패딩"), + SubcategoryItem(id: 41, name: "패딩조끼"), + SubcategoryItem(id: 42, name: "기타") + ] ), CategoryItem( id: 5, name: "신발", - subcategories: ["스니커즈", "부츠/워커", "구두", "샌들/슬리퍼", "기타"] + subcategories: [ + SubcategoryItem(id: 43, name: "스니커즈"), + SubcategoryItem(id: 44, name: "부츠/워커"), + SubcategoryItem(id: 45, name: "구두"), + SubcategoryItem(id: 46, name: "샌들/슬리퍼"), + SubcategoryItem(id: 47, name: "기타") + ] ), CategoryItem( id: 6, name: "가방", - subcategories: ["메신저/크로스백", "숄더백", "백팩", "토트백", "에코백", "기타"] + subcategories: [ + SubcategoryItem(id: 48, name: "메신저/크로스백"), + SubcategoryItem(id: 49, name: "숄더백"), + SubcategoryItem(id: 50, name: "백팩"), + SubcategoryItem(id: 51, name: "토트백"), + SubcategoryItem(id: 52, name: "에코백"), + SubcategoryItem(id: 53, name: "기타") + ] ), CategoryItem( id: 7, - name: "패션소품", - subcategories: ["모자", "머플러", "양말/레그웨어", "시계", "주얼리", "벨트", "선글라스/안경", "기타"] + name: "패션 소품", + subcategories: [ + SubcategoryItem(id: 54, name: "모자"), + SubcategoryItem(id: 55, name: "머플러"), + SubcategoryItem(id: 56, name: "양말/레그웨어"), + SubcategoryItem(id: 57, name: "시계"), + SubcategoryItem(id: 58, name: "주얼리"), + SubcategoryItem(id: 59, name: "벨트"), + SubcategoryItem(id: 60, name: "선글라스/안경"), + SubcategoryItem(id: 61, name: "기타") + ] ) ] // MARK: - Helper Methods - /// ID로 카테고리 조회 + /// ID로 상위 카테고리 조회 static func category(byId id: Int) -> CategoryItem? { return all.first { $0.id == id } } - /// 이름으로 카테고리 조회 + /// 이름으로 상위 카테고리 조회 static func category(byName name: String) -> CategoryItem? { return all.first { $0.name == name } } + + /// 하위 카테고리 ID로 상위 카테고리 조회 + static func category(bySubcategoryId id: Int) -> CategoryItem? { + return all.first { category in + category.subcategories.contains { $0.id == id } + } + } + + /// 하위 카테고리 ID로 하위 카테고리 조회 + static func subcategory(byId id: Int) -> SubcategoryItem? { + for category in all { + if let subcategory = category.subcategories.first(where: { $0.id == id }) { + return subcategory + } + } + return nil + } + + /// 하위 카테고리 이름으로 하위 카테고리 조회 (상위 카테고리 내에서) + static func subcategory(byName name: String, in category: CategoryItem) -> SubcategoryItem? { + return category.subcategories.first { $0.name == name } + } } diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index 738daaa0..bf19cc18 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -8,25 +8,56 @@ import Foundation // MARK: - ClothDataSource Protocol + protocol ClothDataSource { func fetchClothItems(category: String?) async throws -> [ProductItem] - func uploadClothes(_ dtos: [ClothRequestDTO]) async throws -> [ClothResponseDTO] + + /// 옷 저장 (Presigned URL 발급 → S3 업로드 → 옷 생성 API 호출) + func saveClothes(inputs: [ClothInput], images: [Data]) async throws -> [Cloth] // MyCloset 전용 메서드 func fetchMyClosetClothItems( - mainCategory: String?, - subCategory: String?, + categoryId: Int?, seasons: Set, searchText: String? ) async throws -> [Cloth] + + /// 옷 목록 조회 (API 연동) + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) + + /// 옷 상세 조회 (API 연동) + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult + + /// 옷 수정 (API 연동) + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws + + /// 옷 삭제 (API 연동) - 단일 + func deleteCloth(clothId: Int) async throws func deleteClothItems(_ clothIds: [Int]) async throws } // MARK: - DefaultClothDataSource + final class DefaultClothDataSource: ClothDataSource { - // MARK: - Mock Data + // MARK: - Properties + + private let apiService: ClothAPIServiceProtocol + + // MARK: - Initializer + + init(apiService: ClothAPIServiceProtocol = ClothAPIService()) { + self.apiService = apiService + } + + // MARK: - Mock Data (TODO: API 연결 후 제거) + private let mockClothItems: [ProductItem] = [ ProductItem(id: 1, imageName: "sample1", isTodayCloth: true, brand: "Nike", name: "에어포스 1"), ProductItem(id: 2, imageName: "sample2", isTodayCloth: true, brand: "Adidas", name: "후디"), @@ -36,35 +67,8 @@ final class DefaultClothDataSource: ClothDataSource { ProductItem(id: 6, imageName: "sample6", isTodayCloth: false, brand: nil, name: nil) ] - // MyCloset용 Mock Cloth 데이터 - private let mockMyClosetClothItems: [Cloth] = [ - // 상의 - Cloth(id: 1, imageUrl: "sample_tshirt1", name: "오버핏 반팔티", brand: "Uniqlo", categoryId: 1, seasons: [.spring, .summer]), - Cloth(id: 2, imageUrl: "sample_knit1", name: "케이블 니트", brand: "Zara", categoryId: 1, seasons: [.fall, .winter]), - Cloth(id: 3, imageUrl: "sample_hoodie1", name: "후드티", brand: "Nike", categoryId: 1, seasons: [.spring, .fall]), - // 바지 - Cloth(id: 4, imageUrl: "sample_jeans1", name: "블루 청바지", brand: "Levi's", categoryId: 2, seasons: [.spring, .summer, .fall]), - Cloth(id: 5, imageUrl: "sample_slacks1", name: "슬랙스", brand: "Zara", categoryId: 2, seasons: [.spring, .summer, .fall, .winter]), - Cloth(id: 6, imageUrl: "sample_shorts1", name: "반바지", brand: nil, categoryId: 2, seasons: [.summer]), - // 치마 - Cloth(id: 7, imageUrl: "sample_skirt1", name: "미니스커트", brand: "H&M", categoryId: 3, seasons: [.spring, .summer]), - Cloth(id: 8, imageUrl: "sample_dress1", name: "원피스", brand: "Mango", categoryId: 3, seasons: [.summer]), - // 아우터 - Cloth(id: 9, imageUrl: "sample_padding1", name: "숏패딩", brand: "The North Face", categoryId: 4, seasons: [.winter]), - Cloth(id: 10, imageUrl: "sample_coat1", name: "울 코트", brand: "Zara", categoryId: 4, seasons: [.fall, .winter]), - Cloth(id: 11, imageUrl: "sample_cardigan1", name: "가디건", brand: "Uniqlo", categoryId: 4, seasons: [.spring, .fall]), - // 신발 - Cloth(id: 12, imageUrl: "sample_sneakers1", name: "에어포스 1", brand: "Nike", categoryId: 5, seasons: [.spring, .summer, .fall]), - Cloth(id: 13, imageUrl: "sample_boots1", name: "첼시부츠", brand: "Dr.Martens", categoryId: 5, seasons: [.fall, .winter]), - // 가방 - Cloth(id: 14, imageUrl: "sample_backpack1", name: "백팩", brand: "Eastpak", categoryId: 6, seasons: [.spring, .summer, .fall, .winter]), - Cloth(id: 15, imageUrl: "sample_totebag1", name: "토트백", brand: nil, categoryId: 6, seasons: [.spring, .summer]), - // 패션소품 - Cloth(id: 16, imageUrl: "sample_cap1", name: "볼캡", brand: "New Era", categoryId: 7, seasons: [.spring, .summer]), - Cloth(id: 17, imageUrl: "sample_muffler1", name: "머플러", brand: nil, categoryId: 7, seasons: [.fall, .winter]) - ] - // MARK: - Methods + func fetchClothItems(category: String?) async throws -> [ProductItem] { // TODO: 실제 API 호출로 대체 @@ -79,53 +83,135 @@ final class DefaultClothDataSource: ClothDataSource { return mockClothItems } - func uploadClothes(_ dtos: [ClothRequestDTO]) async throws -> [ClothResponseDTO] { - // TODO: 서버 API 호출 구현 - fatalError("Server API not implemented yet") + /// 옷 저장 (전체 흐름: Presigned URL → S3 업로드 → 옷 생성) + func saveClothes(inputs: [ClothInput], images: [Data]) async throws -> [Cloth] { + guard inputs.count == images.count else { + throw ClothDataSourceError.inputImageCountMismatch + } + + // Step 1: Presigned URL 발급 + let presignedInfos = try await apiService.getPresignedUrls(for: images) + + // Step 2: S3에 이미지 업로드 + for (imageData, presignedInfo) in zip(images, presignedInfos) { + try await apiService.uploadImageToS3( + presignedUrl: presignedInfo.presignedUrl, + imageData: imageData, + contentMD5: presignedInfo.md5Hash + ) + } + + // Step 3: 옷 생성 API 호출 + let createRequests = zip(inputs, presignedInfos).map { input, presignedInfo in + ClothCreateAPIRequest( + clothImageUrl: presignedInfo.finalUrl, + clothUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, + name: input.name.isEmpty ? nil : input.name, + brand: input.brand.isEmpty ? nil : input.brand, + seasons: Array(input.seasons), + categoryId: Int64(input.categoryId ?? 0) + ) + } + + let clothIds = try await apiService.createClothes(requests: createRequests) + + // 결과 변환: clothIds + inputs → Cloth 엔티티 + return zip(clothIds, zip(inputs, presignedInfos)).map { clothId, pair in + let (input, presignedInfo) = pair + return Cloth( + id: Int(clothId), + imageUrl: presignedInfo.finalUrl, + name: input.name.isEmpty ? nil : input.name, + brand: input.brand.isEmpty ? nil : input.brand, + purchaseUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, + categoryId: input.categoryId, + seasons: input.seasons + ) + } } func fetchMyClosetClothItems( - mainCategory: String?, - subCategory: String?, + categoryId: Int?, seasons: Set, searchText: String? ) async throws -> [Cloth] { - // TODO: 실제 API 호출로 대체 + let result = try await apiService.fetchClothes( + lastClothId: nil, + size: 100, + categoryId: categoryId.map { Int64($0) }, + seasons: Array(seasons) + ) - var filteredItems = mockMyClosetClothItems + var clothes = result.clothes.map(mapToCloth) - // 1. 메인 카테고리 필터링 - if let mainCategory = mainCategory, mainCategory != "전체" { - if let category = CategoryConstants.category(byName: mainCategory) { - filteredItems = filteredItems.filter { $0.categoryId == category.id } - } - } - - // 2. 서브 카테고리 필터링 - // TODO: Cloth에 subCategoryId 추가되면 구현 - - // 3. 계절 필터링 - if !seasons.isEmpty { - filteredItems = filteredItems.filter { cloth in - !cloth.seasons.isDisjoint(with: seasons) - } - } - - // 4. 검색어 필터링 if let searchText = searchText, !searchText.isEmpty { - filteredItems = filteredItems.filter { cloth in + clothes = clothes.filter { cloth in let nameMatch = cloth.name?.localizedCaseInsensitiveContains(searchText) ?? false let brandMatch = cloth.brand?.localizedCaseInsensitiveContains(searchText) ?? false return nameMatch || brandMatch } } - return filteredItems + return clothes } func deleteClothItems(_ clothIds: [Int]) async throws { - // TODO: 실제 API 호출로 대체 - // Mock 환경: 성공만 반환 - print("Mock: \(clothIds) 삭제 성공") + for clothId in clothIds { + try await apiService.deleteCloth(clothId: Int64(clothId)) + } + } + + // MARK: - API 연동 메서드 + + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) { + let result = try await apiService.fetchClothes( + lastClothId: lastClothId.map { Int64($0) }, + size: Int32(size), + categoryId: categoryId.map { Int64($0) }, + seasons: Array(seasons) + ) + + return (clothes: result.clothes.map(mapToCloth), isLast: result.isLast) + } + + // MARK: - Private Helpers + + private func mapToCloth(_ item: ClothListItem) -> Cloth { + Cloth( + id: Int(item.clothId), + imageUrl: item.imageUrl, + name: item.name, + brand: item.brand + ) + } + + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { + return try await apiService.fetchClothDetails(clothId: Int64(clothId)) + } + + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws { + try await apiService.updateCloth(clothId: Int64(clothId), request: request) + } + + func deleteCloth(clothId: Int) async throws { + try await apiService.deleteCloth(clothId: Int64(clothId)) + } +} + +// MARK: - ClothDataSourceError + +enum ClothDataSourceError: LocalizedError { + case inputImageCountMismatch + + var errorDescription: String? { + switch self { + case .inputImageCountMismatch: + return "입력 데이터와 이미지 개수가 일치하지 않습니다." + } } } diff --git a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift index dfa31b16..af2baee9 100644 --- a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift @@ -8,55 +8,31 @@ import Foundation // MARK: - ClothRepositoryImpl + final class ClothRepositoryImpl: ClothRepository { // MARK: - Properties + private let dataSource: ClothDataSource // MARK: - Initializer + init(dataSource: ClothDataSource) { self.dataSource = dataSource } // MARK: - Methods + func fetchClothItems(category: String?) async throws -> [ProductItem] { return try await dataSource.fetchClothItems(category: category) } func saveClothes(_ inputs: [ClothInput], images: [Data]) async throws -> [Cloth] { - // TODO: 서버 연결 시 아래 로직으로 구현 - // 1. ClothInput + Data → ClothRequestDTO 변환 - // 2. dataSource.uploadClothes(dtos) 호출 - // 3. 서버 응답 ClothResponseDTO → Cloth Entity 변환 후 반환 - - // 임시 구현: 더미 데이터 반환 - return inputs.enumerated().map { index, input in - Cloth( - id: index, - imageUrl: "", // 서버 연결 시 Presigned URL로 업로드 후 받은 URL - name: input.name.isEmpty ? nil : input.name, - brand: input.brand.isEmpty ? nil : input.brand, - purchaseUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, - categoryId: input.categoryId, - seasons: input.seasons - ) - } - - /* 서버 연결 시 실제 구현: - let dtos = zip(inputs, images).map { input, imageData in - ClothRequestDTO( - image: imageData, - name: input.name.isEmpty ? nil : input.name, - brand: input.brand.isEmpty ? nil : input.brand, - purchaseUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, - categoryId: input.categoryId, - seasons: input.seasons.map { $0.rawValue } - ) - } - - let responseDTOs = try await dataSource.uploadClothes(dtos) - return responseDTOs.map { $0.toEntity() } - */ + // DataSource를 통해 전체 흐름 실행: + // 1. Presigned URL 발급 + // 2. S3 업로드 + // 3. 옷 생성 API 호출 + return try await dataSource.saveClothes(inputs: inputs, images: images) } func fetchMyClosetClothItems( @@ -65,9 +41,19 @@ final class ClothRepositoryImpl: ClothRepository { seasons: Set, searchText: String? ) async throws -> [Cloth] { + // 카테고리 ID 변환 (Repository 레이어에서 처리) + var categoryId: Int? + if let subCategory = subCategory { + for category in CategoryConstants.all { + if let sub = category.subcategories.first(where: { $0.name == subCategory }) { + categoryId = sub.id + break + } + } + } + return try await dataSource.fetchMyClosetClothItems( - mainCategory: mainCategory, - subCategory: subCategory, + categoryId: categoryId, seasons: seasons, searchText: searchText ) @@ -76,4 +62,32 @@ final class ClothRepositoryImpl: ClothRepository { func deleteClothItems(_ clothIds: [Int]) async throws { try await dataSource.deleteClothItems(clothIds) } + + // MARK: - API 연동 메서드 + + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) { + return try await dataSource.fetchClothList( + lastClothId: lastClothId, + size: size, + categoryId: categoryId, + seasons: seasons + ) + } + + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { + return try await dataSource.fetchClothDetail(clothId: clothId) + } + + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws { + try await dataSource.updateCloth(clothId: clothId, request: request) + } + + func deleteCloth(clothId: Int) async throws { + try await dataSource.deleteCloth(clothId: clothId) + } } diff --git a/Codive/Features/Closet/Domain/Entities/CategoryItem.swift b/Codive/Features/Closet/Domain/Entities/CategoryItem.swift index ecfe9221..70c6abed 100644 --- a/Codive/Features/Closet/Domain/Entities/CategoryItem.swift +++ b/Codive/Features/Closet/Domain/Entities/CategoryItem.swift @@ -10,5 +10,10 @@ import Foundation struct CategoryItem: Identifiable, Hashable { let id: Int let name: String - let subcategories: [String] + let subcategories: [SubcategoryItem] +} + +struct SubcategoryItem: Identifiable, Hashable { + let id: Int + let name: String } diff --git a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift index 3e239392..798ba373 100644 --- a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift +++ b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift @@ -19,6 +19,23 @@ protocol ClothRepository { seasons: Set, searchText: String? ) async throws -> [Cloth] + + /// 옷 목록 조회 (API 연동) + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) + + /// 옷 상세 조회 (API 연동) + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult + + /// 옷 수정 (API 연동) + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws + + /// 옷 삭제 (API 연동) - 단일 + func deleteCloth(clothId: Int) async throws func deleteClothItems(_ clothIds: [Int]) async throws } diff --git a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift index 8b62f209..104f249c 100644 --- a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift +++ b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift @@ -9,19 +9,53 @@ import SwiftUI struct ClothingCardView: View { + let imageUrl: String? let brand: String let name: String - + + init(imageUrl: String? = nil, brand: String, name: String) { + self.imageUrl = imageUrl + self.brand = brand + self.name = name + } + var body: some View { VStack(alignment: .leading, spacing: 0) { // 이미지 영역 - Rectangle() - .fill(Color.Codive.grayscale4) - .overlay( - Image(systemName: "photo") - .foregroundStyle(Color.Codive.grayscale3) - ) + if let imageUrl = imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.Codive.grayscale5) + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure(let error): + let _ = print(" [ClothingCard] 이미지 로드 실패: \(error)") + Rectangle() + .fill(Color.Codive.grayscale4) + .overlay( + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(Color.red) + ) + @unknown default: + EmptyView() + } + } .frame(height: 124) + .clipped() + } else { + Rectangle() + .fill(Color.Codive.grayscale4) + .overlay( + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale3) + ) + .frame(height: 124) + } // 정보 영역 VStack(alignment: .leading, spacing: 4) { diff --git a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift index e60dca0d..40b22b1d 100644 --- a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift +++ b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift @@ -12,6 +12,7 @@ struct ClothingItem: Identifiable { let id = UUID() let imageName: String? let image: UIImage? + let imageUrl: String? let category: String let subcategory: String let season: String @@ -22,6 +23,7 @@ struct ClothingItem: Identifiable { init( imageName: String? = nil, image: UIImage? = nil, + imageUrl: String? = nil, category: String, subcategory: String, season: String, @@ -31,6 +33,7 @@ struct ClothingItem: Identifiable { ) { self.imageName = imageName self.image = image + self.imageUrl = imageUrl self.category = category self.subcategory = subcategory self.season = season @@ -170,7 +173,33 @@ struct CustomAIRecommendationView: View { .frame(height: geometry.size.width) .background(Color.Codive.grayscale6) .clipShape(RoundedRectangle(cornerRadius: 10)) - } else if let imageName = item.imageName { + } else if let imageUrl = item.imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.Codive.grayscale6) + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Rectangle() + .fill(Color.Codive.grayscale6) + .overlay( + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + ) + @unknown default: + EmptyView() + } + } + .frame(maxWidth: .infinity) + .frame(height: geometry.size.width) + .background(Color.Codive.grayscale6) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else if let imageName = item.imageName, !imageName.isEmpty { Image(imageName) .resizable() .aspectRatio(contentMode: .fit) @@ -178,6 +207,12 @@ struct CustomAIRecommendationView: View { .frame(height: geometry.size.width) .background(Color.Codive.grayscale6) .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Rectangle() + .fill(Color.Codive.grayscale6) + .frame(maxWidth: .infinity) + .frame(height: geometry.size.width) + .clipShape(RoundedRectangle(cornerRadius: 10)) } // Edit button (조건부 표시) diff --git a/Codive/Features/Closet/Presentation/View/ClothAddView.swift b/Codive/Features/Closet/Presentation/View/ClothAddView.swift index c4993454..51626500 100644 --- a/Codive/Features/Closet/Presentation/View/ClothAddView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothAddView.swift @@ -21,44 +21,56 @@ struct ClothAddView: View { // MARK: - Body var body: some View { - VStack(spacing: 0) { - // Navigation Bar - CustomNavigationBar( - title: TextLiteral.Closet.clothAddTitle, - onBack: { - viewModel.dismissView() - }, - rightButton: .text( - title: TextLiteral.Common.complete, - isEnabled: viewModel.isAllFormsValid - ) { - viewModel.completeAdding() - } - ) - - ScrollView { - CustomAIRecommendationView( - title: TextLiteral.Closet.clothLoadedTitle, - items: convertToClothingItems(), - selectedItemIndex: $viewModel.currentIndex, - onCategoryTap: { - viewModel.showCategorySheet() - }, - onSeasonTap: { - viewModel.showSeasonSheet() - }, - onNameChanged: { name in - viewModel.updateName(name) + ZStack { + // 메인 콘텐츠 + VStack(spacing: 0) { + // Navigation Bar + CustomNavigationBar( + title: TextLiteral.Closet.clothAddTitle, + onBack: { + viewModel.dismissView() }, - onBrandChanged: { brand in - viewModel.updateBrand(brand) - }, - onPurchaseUrlChanged: { url in - viewModel.updatePurchaseUrl(url) + rightButton: .text( + title: TextLiteral.Common.complete, + isEnabled: viewModel.isAllFormsValid && !viewModel.isLoading + ) { + viewModel.completeAdding() } ) + + ScrollView { + CustomAIRecommendationView( + title: TextLiteral.Closet.clothLoadedTitle, + items: convertToClothingItems(), + selectedItemIndex: $viewModel.currentIndex, + onCategoryTap: { + viewModel.showCategorySheet() + }, + onSeasonTap: { + viewModel.showSeasonSheet() + }, + onNameChanged: { name in + viewModel.updateName(name) + }, + onBrandChanged: { brand in + viewModel.updateBrand(brand) + }, + onPurchaseUrlChanged: { url in + viewModel.updatePurchaseUrl(url) + } + ) + } + .padding(.top, 10) + } + + // 로딩 인디케이터 + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .scaleEffect(1.5) + .tint(.white) } - .padding(.top, 10) } .navigationBarHidden(true) .background(Color.white) @@ -105,9 +117,11 @@ struct ClothAddView: View { } return ClothingItem( + imageName: nil, image: photo.croppedImage, + imageUrl: nil, category: form.category?.name ?? "", - subcategory: form.subcategory ?? "", + subcategory: form.subcategory?.name ?? "", season: seasonText, name: form.name, brand: form.brand, diff --git a/Codive/Features/Closet/Presentation/View/ClothEditView.swift b/Codive/Features/Closet/Presentation/View/ClothEditView.swift index 81539cd6..153e6d28 100644 --- a/Codive/Features/Closet/Presentation/View/ClothEditView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothEditView.swift @@ -61,6 +61,9 @@ struct ClothEditView: View { } .padding(.top, 10) } + .task { + await viewModel.fetchDetail() + } .navigationBarHidden(true) .background(Color.white) .sheet(isPresented: $viewModel.isCategorySheetPresented) { @@ -105,10 +108,11 @@ struct ClothEditView: View { } return ClothingItem( - imageName: viewModel.cloth.imageUrl, + imageName: nil, image: nil, + imageUrl: viewModel.imageUrl, category: form.category?.name ?? "", - subcategory: form.subcategory ?? "", + subcategory: form.subcategory?.name ?? "", season: seasonText, name: form.name, brand: form.brand, diff --git a/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift b/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift index 8f772945..e780c4ce 100644 --- a/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift +++ b/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift @@ -58,41 +58,77 @@ struct MyClosetSectionView: View { } .frame(height: 200) } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - // 2개씩 묶어서 세로로 표시 - ForEach(stride(from: 0, to: viewModel.clothItems.count, by: 2).map { $0 }, id: \.self) { i in - VStack(spacing: 12) { - // 첫 번째 아이템 - ClothingCardView( - brand: viewModel.clothItems[i].brand ?? "No brand", - name: viewModel.clothItems[i].name ?? "이름 없음" - ) - .onTapGesture { - viewModel.navigateToClothDetail(viewModel.clothItems[i]) - } - - // 두 번째 아이템 (있을 경우) - if i + 1 < viewModel.clothItems.count { - ClothingCardView( - brand: viewModel.clothItems[i + 1].brand ?? "No brand", - name: viewModel.clothItems[i + 1].name ?? "이름 없음" - ) - .onTapGesture { - viewModel.navigateToClothDetail(viewModel.clothItems[i + 1]) - } - } - } - } - } - .padding(.horizontal, 20) - } + clothGridSection } } .task { await viewModel.loadClothItems() } } + + // MARK: - Cloth Grid Section + + @ViewBuilder + private var clothGridSection: some View { + let itemCount = viewModel.clothItems.count + let showTwoRows = itemCount >= 8 + + ScrollView(.horizontal, showsIndicators: false) { + if showTwoRows { + // 8개 이상: 2행 x 4열 (총 8개) + twoRowGrid + } else { + // 7개 이하: 1행 (최대 4개) + oneRowGrid + } + } + } + + /// 1행 레이아웃 (7개 이하일 때, 최대 4개 표시) + @ViewBuilder + private var oneRowGrid: some View { + HStack(spacing: 12) { + ForEach(Array(viewModel.clothItems.prefix(4).enumerated()), id: \.element.id) { _, cloth in + clothCard(for: cloth) + } + } + .padding(.horizontal, 20) + } + + /// 2행 레이아웃 (8개 이상일 때, 총 8개 표시) + @ViewBuilder + private var twoRowGrid: some View { + let displayItems = Array(viewModel.clothItems.prefix(8)) + + HStack(spacing: 12) { + // 4개씩 묶어서 세로로 표시 + ForEach(stride(from: 0, to: displayItems.count, by: 2).map { $0 }, id: \.self) { i in + VStack(spacing: 12) { + // 첫 번째 아이템 (위) + clothCard(for: displayItems[i]) + + // 두 번째 아이템 (아래, 있을 경우) + if i + 1 < displayItems.count { + clothCard(for: displayItems[i + 1]) + } + } + } + } + .padding(.horizontal, 20) + } + + /// 개별 옷 카드 + @ViewBuilder + private func clothCard(for cloth: Cloth) -> some View { + ClothingCardView( + imageUrl: cloth.imageUrl, + brand: cloth.brand ?? "No brand", + name: cloth.name ?? "이름 없음" + ) + .onTapGesture { + viewModel.navigateToClothDetail(cloth) + } + } } #Preview { diff --git a/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift b/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift index 27c2917f..348ef8d5 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift @@ -32,31 +32,34 @@ struct ClothDetailView: View { } ) - ScrollView { - VStack(spacing: 32) { - // 1. 상품 이미지 영역 - Image(viewModel.imageUrl) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .background(Color.Codive.grayscale7) - .clipShape(RoundedRectangle(cornerRadius: 12)) + if viewModel.isLoading { + Spacer() + ProgressView() + Spacer() + } else { + ScrollView { + VStack(spacing: 32) { + // 1. 상품 이미지 영역 + clothImageView - // 2. 정보 리스트 영역 - VStack(spacing: 20) { - infoRow(label: "카테고리", value: viewModel.categoryText) - infoRow(label: "계절", value: viewModel.seasonText) - infoRow(label: "옷 이름", value: viewModel.name) - infoRow(label: "브랜드", value: viewModel.brand) - infoRow(label: "구매 url", value: viewModel.purchaseUrl) + // 2. 정보 리스트 영역 + VStack(spacing: 20) { + infoRow(label: "카테고리", value: viewModel.categoryText) + infoRow(label: "계절", value: viewModel.seasonText) + infoRow(label: "옷 이름", value: viewModel.name) + infoRow(label: "브랜드", value: viewModel.brand) + infoRow(label: "구매 url", value: viewModel.purchaseUrl) + } } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 30) } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 30) } } + .task { + await viewModel.fetchDetail() + } .navigationBarHidden(true) .background(Color.white) .confirmationDialog("", isPresented: $viewModel.showActionSheet) { @@ -80,6 +83,45 @@ struct ClothDetailView: View { } } + // 이미지 뷰 + @ViewBuilder + private var clothImageView: some View { + let urlString = viewModel.imageUrl + if !urlString.isEmpty, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.Codive.grayscale7) + .aspectRatio(1, contentMode: .fit) + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Rectangle() + .fill(Color.Codive.grayscale7) + .aspectRatio(1, contentMode: .fit) + .overlay( + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + ) + @unknown default: + EmptyView() + } + } + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + Rectangle() + .fill(Color.Codive.grayscale7) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + // 공통 정보 행 컴포넌트 @ViewBuilder private func infoRow(label: String, value: String) -> some View { diff --git a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift index 7509c362..538ac611 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift @@ -81,7 +81,7 @@ struct MyClosetView: View { LazyVGrid(columns: columns, spacing: 0) { ForEach(viewModel.clothItems) { cloth in CustomClothCard( - imageName: cloth.imageUrl, + imageUrl: cloth.imageUrl, brand: cloth.brand ?? "", title: cloth.name ?? "", isEditMode: viewModel.isEditMode, @@ -172,21 +172,21 @@ struct MyClosetView: View { return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(subcategories, id: \.self) { sub in - Text(sub) + ForEach(subcategories) { sub in + Text(sub.name) .font(.codive_body2_medium) .padding(.horizontal, 14) .padding(.vertical, 7) .background(Color.white) - .foregroundStyle(viewModel.selectedSubCategory == sub ? Color.Codive.point1 : Color.Codive.grayscale1) + .foregroundStyle(viewModel.selectedSubCategory == sub.name ? Color.Codive.point1 : Color.Codive.grayscale1) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(viewModel.selectedSubCategory == sub ? Color.Codive.point1 : Color.Codive.grayscale6, lineWidth: 1) + .stroke(viewModel.selectedSubCategory == sub.name ? Color.Codive.point1 : Color.Codive.grayscale6, lineWidth: 1) ) .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { - viewModel.updateSubCategory(sub) + viewModel.updateSubCategory(sub.name) } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift index b74ce650..7afd6c03 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift @@ -15,7 +15,7 @@ struct ClothFormData { var brand: String = "" var purchaseUrl: String = "" var category: CategoryItem? - var subcategory: String? + var subcategory: SubcategoryItem? var selectedSeasons: Set = [] } @@ -27,7 +27,7 @@ protocol ClothAddViewModelInput { func updatePurchaseUrl(_ url: String) func showCategorySheet() func showSeasonSheet() - func selectCategory(_ category: CategoryItem, subcategory: String) + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) func selectSeasons(_ seasons: Set) func moveToPrevious() func moveToNext() @@ -70,6 +70,9 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd @Published var isSeasonSheetPresented = false @Published var tempSelectedCategory: CategoryItem? + // 완료 상태 + @Published var isLoading = false + // MARK: - Dependencies private let navigationRouter: NavigationRouter private let addClothUseCase: AddClothUseCase @@ -91,7 +94,7 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd var categoryDisplayText: String { if let category = currentForm.category, let subcategory = currentForm.subcategory { - return "\(category.name) > \(subcategory)" + return "\(category.name) > \(subcategory.name)" } return "" } @@ -165,7 +168,7 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd isSeasonSheetPresented = true } - func selectCategory(_ category: CategoryItem, subcategory: String) { + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) { clothForms[currentIndex].category = category clothForms[currentIndex].subcategory = subcategory isCategorySheetPresented = false @@ -193,6 +196,9 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd } func completeAdding() { + guard !isLoading else { return } + isLoading = true + Task { do { // UIImage → Data 변환 @@ -209,7 +215,7 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd name: form.name, brand: form.brand, purchaseUrl: form.purchaseUrl, - categoryId: nil, // TODO: 서버 연결 시 category name → server ID 매핑 필요 + categoryId: form.subcategory?.id, // 하위 카테고리 ID 사용! seasons: form.selectedSeasons ) } @@ -220,11 +226,17 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd images: imageDatas ) - // TODO: 성공 후 화면 전환 + // 성공: 성공 오버레이 표시 + 뒤에서 탭 전환/네비게이션 + isLoading = false + navigationRouter.showSuccessAndNavigate( + message: "옷장에 옷을 보관했어요!", + to: .closet, + destination: .myCloset, + duration: 1.5 + ) } catch { - // 에러 처리 - print("옷 저장 실패: \(error.localizedDescription)") - // TODO: 에러 알럿 표시 + isLoading = false + // TODO: 에러 메시지를 UI에 표시 (errorMessage 프로퍼티 추가 필요) } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift index 1c0500df..c1987b2d 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift @@ -15,29 +15,43 @@ final class ClothDetailViewModel: ObservableObject { let cloth: Cloth @Published var showDeleteAlert: Bool = false @Published var showActionSheet: Bool = false + @Published var isLoading: Bool = false + @Published var detailData: ClothDetailResult? // MARK: - Computed Properties var imageUrl: String { - cloth.imageUrl + detailData?.clothImageUrl ?? cloth.imageUrl } var name: String { - cloth.name ?? "이름 없음" + detailData?.name ?? cloth.name ?? "이름 없음" } var brand: String { - cloth.brand ?? "브랜드 없음" + detailData?.brand ?? cloth.brand ?? "브랜드 없음" } var categoryText: String { + // API 응답이 있으면 parentCategory > category 형식 사용 + if let detail = detailData { + let parent = detail.parentCategory ?? "" + let child = detail.category ?? "" + if !parent.isEmpty && !child.isEmpty { + return "\(parent) > \(child)" + } else if !parent.isEmpty { + return parent + } else if !child.isEmpty { + return child + } + } + + // fallback: 기존 로직 guard let categoryId = cloth.categoryId, let category = CategoryConstants.category(byId: categoryId) else { return "카테고리 없음" } - return category.name - // TODO: 서브 카테고리 추가되면 "상의 > 티셔츠" 형식으로 변경 } var seasonText: String { @@ -51,24 +65,40 @@ final class ClothDetailViewModel: ObservableObject { } var purchaseUrl: String { - cloth.purchaseUrl ?? "URL 없음" + detailData?.clothUrl ?? cloth.purchaseUrl ?? "URL 없음" } // MARK: - Private Properties private let navigationRouter: NavigationRouter private let deleteClothItemsUseCase: DeleteClothItemsUseCase + private let clothRepository: ClothRepository // MARK: - Initializer init( cloth: Cloth, navigationRouter: NavigationRouter, - deleteClothItemsUseCase: DeleteClothItemsUseCase + deleteClothItemsUseCase: DeleteClothItemsUseCase, + clothRepository: ClothRepository ) { self.cloth = cloth self.navigationRouter = navigationRouter self.deleteClothItemsUseCase = deleteClothItemsUseCase + self.clothRepository = clothRepository + } + + // MARK: - Fetch Detail + + func fetchDetail() async { + isLoading = true + do { + let result = try await clothRepository.fetchClothDetail(clothId: cloth.id) + detailData = result + } catch { + // 에러는 무시 (UI에서 기존 데이터 사용) + } + isLoading = false } // MARK: - Actions @@ -93,11 +123,9 @@ final class ClothDetailViewModel: ObservableObject { func confirmDelete() async { do { try await deleteClothItemsUseCase.execute(clothIds: [cloth.id]) - // 삭제 성공 시 뒤로가기 navigationRouter.navigateBack() } catch { - // TODO: 에러 처리 - print("삭제 실패: \(error)") + // TODO: 에러 메시지를 UI에 표시 (errorMessage 프로퍼티 추가 필요) } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift index 706b1c54..681b63b8 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift @@ -17,7 +17,7 @@ protocol ClothEditViewModelInput { func updatePurchaseUrl(_ url: String) func showCategorySheet() func showSeasonSheet() - func selectCategory(_ category: CategoryItem, subcategory: String) + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) func selectSeasons(_ seasons: Set) func dismissView() func completeEditing() @@ -44,6 +44,10 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Output Properties let cloth: Cloth @Published var clothForm: ClothFormData + @Published var isLoading = false + @Published var isFetching = false + @Published var errorMessage: String? + @Published var imageUrl: String = "" // Sheet states @Published var isCategorySheetPresented = false @@ -52,12 +56,12 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Dependencies private let navigationRouter: NavigationRouter - // TODO: UpdateClothUseCase 추가 필요 + private let clothRepository: ClothRepository // MARK: - Computed Properties var categoryDisplayText: String { if let category = clothForm.category, let subcategory = clothForm.subcategory { - return "\(category.name) > \(subcategory)" + return "\(category.name) > \(subcategory.name)" } return "" } @@ -79,15 +83,17 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Initializer init( cloth: Cloth, - navigationRouter: NavigationRouter + navigationRouter: NavigationRouter, + clothRepository: ClothRepository ) { self.cloth = cloth self.navigationRouter = navigationRouter + self.clothRepository = clothRepository + self.imageUrl = cloth.imageUrl - // 기존 Cloth 데이터로 폼 초기화 - let category = cloth.categoryId.flatMap { CategoryConstants.category(byId: $0) } - // TODO: 서버에서 subcategory 정보가 오면 설정 - let subcategory = category?.subcategories.first + // 기존 Cloth 데이터로 폼 초기화 (목록에서 전달받은 기본 정보) + let subcategory = cloth.categoryId.flatMap { CategoryConstants.subcategory(byId: $0) } + let category = cloth.categoryId.flatMap { CategoryConstants.category(bySubcategoryId: $0) } self.clothForm = ClothFormData( name: cloth.name ?? "", @@ -99,6 +105,41 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth ) } + // MARK: - Fetch Detail + func fetchDetail() async { + isFetching = true + do { + let detail = try await clothRepository.fetchClothDetail(clothId: cloth.id) + + // 이미지 URL 업데이트 + imageUrl = detail.clothImageUrl + + // 카테고리 업데이트 (API 응답의 category 이름으로 찾기) + if let categoryName = detail.parentCategory, + let subcategoryName = detail.category { + if let parentCategory = CategoryConstants.all.first(where: { $0.name == categoryName }), + let subcategory = parentCategory.subcategories.first(where: { $0.name == subcategoryName }) { + clothForm.category = parentCategory + clothForm.subcategory = subcategory + } + } + + // 이름, 브랜드, URL 업데이트 (기존 값이 없으면) + if clothForm.name.isEmpty, let name = detail.name { + clothForm.name = name + } + if clothForm.brand.isEmpty, let brand = detail.brand { + clothForm.brand = brand + } + if clothForm.purchaseUrl.isEmpty, let url = detail.clothUrl { + clothForm.purchaseUrl = url + } + } catch { + // 에러는 무시 (기존 데이터 사용) + } + isFetching = false + } + // MARK: - Input Methods func updateName(_ name: String) { @@ -122,7 +163,7 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth isSeasonSheetPresented = true } - func selectCategory(_ category: CategoryItem, subcategory: String) { + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) { clothForm.category = category clothForm.subcategory = subcategory isCategorySheetPresented = false @@ -138,10 +179,37 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth } func completeEditing() { + guard !isLoading else { return } + guard let subcategory = clothForm.subcategory else { + errorMessage = "카테고리를 선택해주세요" + return + } + guard !clothForm.selectedSeasons.isEmpty else { + errorMessage = "계절을 선택해주세요" + return + } + + isLoading = true + errorMessage = nil + Task { - // TODO: UpdateClothUseCase 구현 후 연결 - print("옷 수정 완료: \(cloth.id)") - navigationRouter.navigateBack() + do { + let request = ClothUpdateAPIRequest( + clothImageUrl: imageUrl, + clothUrl: clothForm.purchaseUrl.isEmpty ? nil : clothForm.purchaseUrl, + name: clothForm.name.isEmpty ? nil : clothForm.name, + brand: clothForm.brand.isEmpty ? nil : clothForm.brand, + seasons: Array(clothForm.selectedSeasons), + categoryId: Int64(subcategory.id) + ) + + try await clothRepository.updateCloth(clothId: cloth.id, request: request) + isLoading = false + navigationRouter.navigateBack() + } catch { + isLoading = false + errorMessage = "수정 실패: \(error.localizedDescription)" + } } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift index 927542d5..bae38b55 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift @@ -39,16 +39,17 @@ final class MyClosetSectionViewModel: ObservableObject { do { // 전체 옷 목록 가져오기 (필터 없음) let allItems = try await fetchMyClosetClothItemsUseCase.execute( - mainCategory: "전체", + mainCategory: nil, subCategory: nil, seasons: [], searchText: nil ) - // 최대 10개만 표시 - clothItems = Array(allItems.prefix(10)) + // 최대 8개만 표시 + clothItems = Array(allItems.prefix(8)) + print("[MyClosetSection] 옷 \(clothItems.count)개 로드 완료") } catch { - print("Error loading cloth items: \(error)") + print("[MyClosetSection] 옷 로딩 실패: \(error)") } isLoading = false diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift index 4898587b..c17cf261 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift @@ -138,13 +138,8 @@ final class MyClosetViewModel: ObservableObject { func updateMainCategory(_ category: String) { selectedMainCategory = category - - // 메인 카테고리 변경 시 첫 번째 서브 카테고리 자동 선택 - if let firstSub = CategoryConstants.all.first(where: { $0.name == category })?.subcategories.first { - selectedSubCategory = firstSub - } else { - selectedSubCategory = "" - } + // 메인 카테고리 변경 시 서브 카테고리 선택 초기화 (전체 보기) + selectedSubCategory = "" } func updateSubCategory(_ subCategory: String) { diff --git a/Codive/Features/Feed/Data/Constants/SituationConstants.swift b/Codive/Features/Feed/Data/Constants/SituationConstants.swift new file mode 100644 index 00000000..8ba73c2a --- /dev/null +++ b/Codive/Features/Feed/Data/Constants/SituationConstants.swift @@ -0,0 +1,52 @@ +// +// SituationConstants.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation + +// MARK: - Situation Item + +struct SituationItem: Identifiable, Hashable { + let id: Int64 + let name: String + + var situationId: Int64 { id } +} + +// MARK: - Situation Constants + +enum SituationConstants { + + static let all: [SituationItem] = [ + SituationItem(id: 1, name: "데일리"), + SituationItem(id: 2, name: "여행"), + SituationItem(id: 3, name: "데이트"), + SituationItem(id: 4, name: "파티"), + SituationItem(id: 5, name: "출근룩"), + SituationItem(id: 6, name: "운동"), + SituationItem(id: 7, name: "축제") + ] + + /// 이름으로 SituationItem 찾기 + static func find(byName name: String) -> SituationItem? { + all.first { $0.name == name } + } + + /// ID로 SituationItem 찾기 + static func find(byId id: Int64) -> SituationItem? { + all.first { $0.id == id } + } + + /// 이름으로 ID 가져오기 + static func getId(from name: String) -> Int64? { + find(byName: name)?.id + } + + /// 이름 Set에서 첫 번째 ID 가져오기 + static func getFirstId(from names: Set) -> Int64? { + names.compactMap { find(byName: $0)?.id }.first + } +} diff --git a/Codive/Features/Feed/Data/Constants/StyleConstants.swift b/Codive/Features/Feed/Data/Constants/StyleConstants.swift new file mode 100644 index 00000000..2069ac52 --- /dev/null +++ b/Codive/Features/Feed/Data/Constants/StyleConstants.swift @@ -0,0 +1,56 @@ +// +// StyleConstants.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation + +// MARK: - Style Item + +struct StyleItem: Identifiable, Hashable { + let id: Int64 + let name: String + + var styleId: Int64 { id } +} + +// MARK: - Style Constants + +enum StyleConstants { + + static let all: [StyleItem] = [ + StyleItem(id: 1, name: "캐주얼"), + StyleItem(id: 2, name: "스트릿"), + StyleItem(id: 3, name: "미니멀"), + StyleItem(id: 4, name: "클래식"), + StyleItem(id: 5, name: "시크"), + StyleItem(id: 6, name: "빈티지"), + StyleItem(id: 7, name: "걸리시"), + StyleItem(id: 8, name: "스포티"), + StyleItem(id: 9, name: "러블리"), + StyleItem(id: 10, name: "오피스룩"), + StyleItem(id: 11, name: "하이틴") + ] + + /// 이름으로 StyleItem 찾기 + static func find(byName name: String) -> StyleItem? { + all.first { $0.name == name } + } + + /// ID로 StyleItem 찾기 + static func find(byId id: Int64) -> StyleItem? { + all.first { $0.id == id } + } + + /// 이름 배열로 ID 배열 변환 + static func getIds(from names: [String]) -> [Int64] { + names.compactMap { find(byName: $0)?.id } + } + + /// 이름 Set으로 ID 배열 변환 + static func getIds(from names: Set) -> [Int64] { + getIds(from: Array(names)) + } +} diff --git a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift index 103576ec..c369afe5 100644 --- a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift @@ -6,14 +6,112 @@ // import Foundation +import UIKit + +// MARK: - Record Create Request + +struct RecordCreateRequest { + let content: String? + let situationId: Int64 + let styleIds: [Int64] + let hashtags: [String] + let photos: [RecordPhoto] +} + +struct RecordPhoto { + let image: UIImage + let clothTags: [RecordClothTag] +} + +struct RecordClothTag { + let clothId: Int64 + let locationX: Double + let locationY: Double +} + +// MARK: - Protocol protocol RecordDataSource { - func create(record: Record) async -> Bool + func createRecord(request: RecordCreateRequest) async throws -> Int64 } +// MARK: - Implementation + final class DefaultRecordDataSource: RecordDataSource { - func create(record: Record) async -> Bool { - print("Creating record on remote server: \(record)") - return true + + private let clothAPIService: ClothAPIServiceProtocol + private let historyAPIService: HistoryAPIServiceProtocol + + init( + clothAPIService: ClothAPIServiceProtocol = ClothAPIService(), + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { + self.clothAPIService = clothAPIService + self.historyAPIService = historyAPIService + } + + func createRecord(request: RecordCreateRequest) async throws -> Int64 { + // Step 1: 이미지 업로드 (Presigned URL → S3) + let imageUrls = try await uploadImages(photos: request.photos) + + // Step 2: API 요청 생성 + let payloads = zip(imageUrls, request.photos).map { url, photo in + HistoryImagePayload( + imageUrl: url, + clothTags: photo.clothTags + ) + } + + let apiRequest = HistoryCreateAPIRequest( + content: request.content, + situationId: request.situationId, + styleIds: request.styleIds, + hashtags: request.hashtags, + payloads: payloads + ) + + // Step 3: 기록 생성 API 호출 + return try await historyAPIService.createHistory(request: apiRequest) + } + + // MARK: - Private Methods + + private func uploadImages(photos: [RecordPhoto]) async throws -> [String] { + // 이미지 데이터 변환 + let imageDatas = photos.compactMap { photo -> Data? in + photo.image.jpegData(compressionQuality: 0.8) + } + + guard imageDatas.count == photos.count else { + throw RecordDataSourceError.imageConversionFailed + } + + // Presigned URL 발급 (옷 추가 API 재사용) + let presignedInfos = try await clothAPIService.getPresignedUrls(for: imageDatas) + + // S3 업로드 + for (imageData, presignedInfo) in zip(imageDatas, presignedInfos) { + try await clothAPIService.uploadImageToS3( + presignedUrl: presignedInfo.presignedUrl, + imageData: imageData, + contentMD5: presignedInfo.md5Hash + ) + } + + // 최종 URL 반환 + return presignedInfos.map { $0.finalUrl } + } +} + +// MARK: - Error + +enum RecordDataSourceError: LocalizedError { + case imageConversionFailed + + var errorDescription: String? { + switch self { + case .imageConversionFailed: + return "이미지 변환에 실패했습니다." + } } } diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift new file mode 100644 index 00000000..3e6817df --- /dev/null +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -0,0 +1,123 @@ +// +// HistoryAPIService.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - History API Service Protocol + +protocol HistoryAPIServiceProtocol { + func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 +} + +// MARK: - Request Types + +struct HistoryCreateAPIRequest { + let content: String? + let situationId: Int64 + let styleIds: [Int64] + let hashtags: [String] + let payloads: [HistoryImagePayload] +} + +struct HistoryImagePayload { + let imageUrl: String + let clothTags: [RecordClothTag] +} + +// MARK: - History API Service Implementation + +final class HistoryAPIService: HistoryAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + // MARK: - Create History + + func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 { + let payloads = request.payloads.map { payload in + Components.Schemas.HistoryCreatePayload( + imageUrl: payload.imageUrl, + clothTags: payload.clothTags.map { tag in + Components.Schemas.ClothTag( + clothId: tag.clothId, + locationX: tag.locationX, + locationY: tag.locationY + ) + } + ) + } + + let hashtagContainers: [OpenAPIRuntime.OpenAPIValueContainer]? = request.hashtags.isEmpty + ? nil + : request.hashtags.compactMap { try? OpenAPIRuntime.OpenAPIValueContainer(unvalidatedValue: $0) } + + let requestBody = Components.Schemas.HistoryCreateRequest( + content: request.content, + situationId: request.situationId, + styleIds: request.styleIds, + hashtags: hashtagContainers, + payloads: payloads + ) + + let input = Operations.History_createHistory.Input(body: .json(requestBody)) + let response = try await client.History_createHistory(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + let decoded = try jsonDecoder.decode(HistoryCreateResponse.self, from: data) + + guard let historyId = decoded.result?.historyId else { + throw HistoryAPIError.noData + } + + return historyId + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } +} + +// MARK: - Response Types + +private struct HistoryCreateResponse: Decodable { + let isSuccess: Bool? + let code: String? + let message: String? + let result: HistoryResult? + + struct HistoryResult: Decodable { + let historyId: Int64? + } +} + +// MARK: - Error + +enum HistoryAPIError: LocalizedError { + case noData + case serverError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .noData: + return "응답 데이터가 없습니다." + case .serverError(let code): + return "서버 오류 (\(code))" + } + } +} diff --git a/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift index 24c1beb7..04e0389c 100644 --- a/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift @@ -9,12 +9,12 @@ import Foundation final class DefaultRecordRepository: RecordRepository { private let dataSource: RecordDataSource - - init(dataSource: RecordDataSource) { + + init(dataSource: RecordDataSource = DefaultRecordDataSource()) { self.dataSource = dataSource } - - func create(record: Record) async -> Bool { - return await dataSource.create(record: record) + + func createRecord(request: RecordCreateRequest) async throws -> Int64 { + return try await dataSource.createRecord(request: request) } } diff --git a/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift b/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift index 0d77aac1..1e4b840e 100644 --- a/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift @@ -8,5 +8,5 @@ import Foundation protocol RecordRepository { - func create(record: Record) async -> Bool + func createRecord(request: RecordCreateRequest) async throws -> Int64 } diff --git a/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift b/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift index 4eb22acb..057c6be4 100644 --- a/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift +++ b/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift @@ -8,17 +8,17 @@ import Foundation protocol CreateRecordUseCase { - func create(record: Record) async -> Bool + func execute(request: RecordCreateRequest) async throws -> Int64 } final class DefaultCreateRecordUseCase: CreateRecordUseCase { private let repository: RecordRepository - - init(repository: RecordRepository) { + + init(repository: RecordRepository = DefaultRecordRepository()) { self.repository = repository } - - func create(record: Record) async -> Bool { - return await repository.create(record: record) + + func execute(request: RecordCreateRequest) async throws -> Int64 { + return try await repository.createRecord(request: request) } } diff --git a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift index 30de794f..60f48bd2 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift @@ -13,24 +13,29 @@ import Combine // MARK: - RecordDetailViewModel @MainActor final class RecordDetailViewModel: ObservableObject { - + // MARK: - Properties private var cancellables = Set() - + @Published var selectedPhotos: [SelectedPhoto] @Published var currentPhotoIndex: Int = 0 - + // MultiSelect Properties @Published var selectedStyles: Set = [] @Published var selectedSituations: Set = [] - + // TextField Property @Published var captionText: String = "" // Alert Property @Published var showExitAlert: Bool = false + // Loading & Error State + @Published var isLoading: Bool = false + @Published var errorMessage: String? + private let navigationRouter: NavigationRouter + private let recordDataSource: RecordDataSource // MARK: - Options let styleOptions = [ @@ -67,10 +72,15 @@ final class RecordDetailViewModel: ObservableObject { } // MARK: - Initializer - init(selectedPhotos: [SelectedPhoto], navigationRouter: NavigationRouter) { + init( + selectedPhotos: [SelectedPhoto], + navigationRouter: NavigationRouter, + recordDataSource: RecordDataSource = DefaultRecordDataSource() + ) { self.selectedPhotos = selectedPhotos self.navigationRouter = navigationRouter - + self.recordDataSource = recordDataSource + // 태그 업데이트 구독 setupPhotoTagSubscription() } @@ -81,14 +91,79 @@ final class RecordDetailViewModel: ObservableObject { } func completeRecord() { - // TODO: 기록 저장 로직 - print("기록 완료") - print("선택된 스타일: \(selectedStyles)") - print("선택된 상황: \(selectedSituations)") - print("캡션: \(captionText)") - - // 메인으로 돌아가기 - navigationRouter.navigateToRoot() + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + + Task { + do { + // 1. 스타일 ID 변환 + let styleIds = StyleConstants.getIds(from: selectedStyles) + guard !styleIds.isEmpty else { + throw RecordError.noStyleSelected + } + + // 2. 상황 ID 변환 (첫 번째 선택) + guard let situationId = SituationConstants.getFirstId(from: selectedSituations) else { + throw RecordError.noSituationSelected + } + + // 3. 해시태그 추출 + let hashtags = extractHashtags(from: captionText) + + // 4. 사진 데이터 변환 + let photos = selectedPhotos.map { photo in + RecordPhoto( + image: photo.croppedImage, + clothTags: photo.clothTags.map { tag in + RecordClothTag( + clothId: Int64(tag.clothId), + locationX: Double(tag.locationX), + locationY: Double(tag.locationY) + ) + } + ) + } + + // 5. 요청 생성 + let request = RecordCreateRequest( + content: captionText.isEmpty ? nil : captionText, + situationId: situationId, + styleIds: styleIds, + hashtags: hashtags, + photos: photos + ) + + // 6. API 호출 + _ = try await recordDataSource.createRecord(request: request) + + // 7. 성공 시 메인으로 + isLoading = false + navigationRouter.navigateToRoot() + + } catch { + isLoading = false + errorMessage = error.localizedDescription + } + } + } + + // MARK: - Helper Methods + + private func extractHashtags(from text: String) -> [String] { + let pattern = "#[가-힣a-zA-Z0-9_]+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + + return matches.compactMap { match in + guard let range = Range(match.range, in: text) else { return nil } + return String(text[range]) + } } func dismissView() { @@ -122,3 +197,19 @@ final class RecordDetailViewModel: ObservableObject { navigationRouter.navigate(to: .photoTag(photo: currentPhoto, allPhotos: selectedPhotos)) } } + +// MARK: - Record Error + +enum RecordError: LocalizedError { + case noStyleSelected + case noSituationSelected + + var errorDescription: String? { + switch self { + case .noStyleSelected: + return "스타일을 선택해주세요." + case .noSituationSelected: + return "상황을 선택해주세요." + } + } +} diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 3ef092e6..77e09dfa 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -43,80 +43,97 @@ struct MainTabView: View { // MARK: - Body var body: some View { - NavigationStack(path: $navigationRouter.path) { - // 최상위 ZStack: 여기서 시트를 띄워야 전체(상단바 포함)를 덮습니다. - ZStack(alignment: .bottom) { - VStack(spacing: 0) { - // 1. 상단바 - if shouldShowTopBar { - TopNavigationBar( - showSearchButton: showSearchButton, - showNotificationButton: showNotificationButton, - onSearchTap: viewModel.handleSearchTap, - onNotificationTap: viewModel.handleNotificationTap - ) - } + ZStack { + NavigationStack(path: $navigationRouter.path) { + // 최상위 ZStack: 여기서 시트를 띄워야 전체(상단바 포함)를 덮습니다. + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + // 1. 상단바 + if shouldShowTopBar { + TopNavigationBar( + showSearchButton: showSearchButton, + showNotificationButton: showNotificationButton, + onSearchTap: viewModel.handleSearchTap, + onNotificationTap: viewModel.handleNotificationTap + ) + } - // 2. 메인 콘텐츠 영역 - Group { - switch viewModel.selectedTab { - case .home: - HomeView(homeDIContainer: homeDIContainer, viewModel: homeViewModel) - .ignoresSafeArea(.all, edges: .bottom) - case .closet: - ClosetView(closetDIContainer: closetDIContainer) - case .add: - AddView(addDIContainer: addDIContainer) - .ignoresSafeArea(.all, edges: .bottom) - case .feed: - FeedView(viewModel: feedDIContainer.makeFeedViewModel()) - case .profile: - ProfileView() + // 2. 메인 콘텐츠 영역 + Group { + switch viewModel.selectedTab { + case .home: + HomeView(homeDIContainer: homeDIContainer, viewModel: homeViewModel) + .ignoresSafeArea(.all, edges: .bottom) + case .closet: + ClosetView(closetDIContainer: closetDIContainer) + case .add: + AddView(addDIContainer: addDIContainer) + .ignoresSafeArea(.all, edges: .bottom) + case .feed: + FeedView(viewModel: feedDIContainer.makeFeedViewModel()) + case .profile: + ProfileView() + } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) - // 3. 하단 탭바 - TabBar(selectedTab: $viewModel.selectedTab) - .zIndex(shouldShowTabBar ? 1 : 0) - } + // 3. 하단 탭바 + TabBar(selectedTab: $viewModel.selectedTab) + .zIndex(shouldShowTabBar ? 1 : 0) + .allowsHitTesting(shouldShowTabBar) + } + .navigationDestination(for: AppDestination.self) { destination in + destinationView(for: destination) + } + .ignoresSafeArea(.keyboard, edges: .bottom) - // 4. 바텀시트: VStack(상단바+컨텐츠+탭바) 위에 배치하여 전체를 딤 처리 - if homeViewModel.showLookBookSheet { - AddBottomSheet( - isPresented: $homeViewModel.showLookBookSheet, - entities: homeViewModel.lookBookList, - thumbnailProvider: { entity in - AsyncImage(url: URL(string: entity.imageUrl)) { image in - image.resizable().scaledToFill() - } placeholder: { - ProgressView() + // 4. 바텀시트: VStack(상단바+컨텐츠+탭바) 위에 배치하여 전체를 딤 처리 + if homeViewModel.showLookBookSheet { + AddBottomSheet( + isPresented: $homeViewModel.showLookBookSheet, + entities: homeViewModel.lookBookList, + thumbnailProvider: { entity in + AsyncImage(url: URL(string: entity.imageUrl)) { image in + image.resizable().scaledToFill() + } placeholder: { + ProgressView() + } + }, + onTapEntity: { entity in + homeViewModel.selectLookBook(entity) } - }, - onTapEntity: { entity in - homeViewModel.selectLookBook(entity) - } - ) - .zIndex(100) // 가장 높은 숫자로 설정 - .transition(.move(edge: .bottom)) + ) + .zIndex(100) // 가장 높은 숫자로 설정 + .transition(.move(edge: .bottom)) + } + + if homeViewModel.showCompletePopUp { + CompletePopUp( + isPresented: $homeViewModel.showCompletePopUp, + onRecordTapped: homeViewModel.handlePopupRecord, + onCloseTapped: homeViewModel.handlePopupClose, + selectedClothes: homeViewModel.selectedCodiClothes + ) + .zIndex(200) + } } - - if homeViewModel.showCompletePopUp { - CompletePopUp( - isPresented: $homeViewModel.showCompletePopUp, - onRecordTapped: homeViewModel.handlePopupRecord, - onCloseTapped: homeViewModel.handlePopupClose, - selectedClothes: homeViewModel.selectedCodiClothes - ) - .zIndex(200) + .environmentObject(navigationRouter) + .onReceive(navigationRouter.$pendingTabSwitch) { tab in + if let tab = tab { + viewModel.selectedTab = tab + navigationRouter.pendingTabSwitch = nil + } } } - .navigationDestination(for: AppDestination.self) { destination in - destinationView(for: destination) + + // MARK: - Success Overlay + if let message = navigationRouter.successMessage { + CustomSuccessView(message: message) + .ignoresSafeArea() + .zIndex(100) + .transition(.opacity) } - .ignoresSafeArea(.keyboard, edges: .bottom) } - .environmentObject(navigationRouter) } // MARK: - Computed Properties @@ -184,4 +201,4 @@ struct MainTabView: View { EmptyView() } } -} +} \ No newline at end of file diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index 4f15213b..63c55422 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -10,6 +10,7 @@ import Foundation enum AppDestination: Hashable, Identifiable { case login case signup + case termsAgreement case main case recordAdd case clothPhotoSelect diff --git a/Codive/Router/AppRouter.swift b/Codive/Router/AppRouter.swift index 1bdf9bdf..f8c2ae59 100644 --- a/Codive/Router/AppRouter.swift +++ b/Codive/Router/AppRouter.swift @@ -10,9 +10,10 @@ import SwiftUI // 앱의 최상위 상태 정의 enum AppState { - case splash // 스플래시 화면 - case auth // 인증 플로우 (온보딩/로그인) - case main // 메인 플로우 + case splash // 스플래시 화면 + case auth // 인증 플로우 (온보딩/로그인) + case termsAgreement // 약관 동의 화면 + case main // 메인 플로우 } // 상태 전환 라우터 @@ -20,6 +21,7 @@ enum AppState { final class AppRouter: ObservableObject { @Published var currentAppState: AppState + @Published var isLoading: Bool = false // 로딩 상태 init() { // 앱 시작시 스플래시부터 시작 @@ -32,10 +34,24 @@ final class AppRouter: ObservableObject { currentAppState = .auth } + func navigateToTerms() { + isLoading = false + currentAppState = .termsAgreement + } + func navigateToMain() { + isLoading = false currentAppState = .main } + func showLoading() { + isLoading = true + } + + func hideLoading() { + isLoading = false + } + func logout() { currentAppState = .auth } diff --git a/Codive/Router/NavigationRouter.swift b/Codive/Router/NavigationRouter.swift index 98216b5f..c6e74397 100644 --- a/Codive/Router/NavigationRouter.swift +++ b/Codive/Router/NavigationRouter.swift @@ -16,6 +16,12 @@ final class NavigationRouter: ObservableObject { @Published var currentDestination: AppDestination? @Published var sheetDestination: AppDestination? + /// 탭 전환 요청 (MainTabView에서 구독) + @Published var pendingTabSwitch: TabBarType? + + /// 성공 오버레이 표시 (앱 레벨에서 관리) + @Published var successMessage: String? + // MARK: - Navigation Methods /// 새로운 화면으로 이동 (스택에 추가) @@ -52,6 +58,38 @@ final class NavigationRouter: ObservableObject { currentDestination = destination path.append(destination) } + + /// 탭 전환 후 특정 화면으로 이동 + func switchTabAndNavigate(to tab: TabBarType, destination: AppDestination? = nil) { + path = NavigationPath() + currentDestination = nil + pendingTabSwitch = tab + + if let destination = destination { + Task { + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + navigate(to: destination) + } + } + } + + /// 성공 화면을 보여주면서 탭 전환 후 네비게이션 (성공 화면이 덮고 있는 동안 뒤에서 이동) + func showSuccessAndNavigate(message: String, to tab: TabBarType, destination: AppDestination? = nil, duration: TimeInterval = 1.5) { + // 1. 성공 오버레이 표시 + successMessage = message + + Task { + // 2. 뒤에서 탭 전환 + 네비게이션 + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + switchTabAndNavigate(to: tab, destination: destination) + + // 3. duration 후 성공 오버레이 닫기 + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + withAnimation(.easeOut(duration: 0.3)) { + successMessage = nil + } + } + } // MARK: - Sheet Presentation Methods diff --git a/Codive/Router/ViewFactory/AuthViewFactory.swift b/Codive/Router/ViewFactory/AuthViewFactory.swift index 0f3bb392..db025d9e 100644 --- a/Codive/Router/ViewFactory/AuthViewFactory.swift +++ b/Codive/Router/ViewFactory/AuthViewFactory.swift @@ -28,6 +28,9 @@ final class AuthViewFactory { case .signup: // SignUpView(viewModel: authDIContainer.makeSignUpViewModel()) Text("회원가입 화면") // 임시 + case .termsAgreement: + // AppRootView에서 직접 처리하므로 여기서는 사용되지 않음 + TermsAgreementView(onComplete: {}) default: EmptyView() } diff --git a/Codive/Shared/Data/Network/JSONDecoderFactory.swift b/Codive/Shared/Data/Network/JSONDecoderFactory.swift new file mode 100644 index 00000000..f5e99eb3 --- /dev/null +++ b/Codive/Shared/Data/Network/JSONDecoderFactory.swift @@ -0,0 +1,55 @@ +// +// JSONDecoderFactory.swift +// Codive +// +// Created by 황상환 on 1/14/26. +// + +import Foundation + +// MARK: - JSONDecoderFactory + +enum JSONDecoderFactory { + + /// API 응답용 JSONDecoder (다양한 날짜 형식 지원) + static func makeAPIDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + // ISO8601 표준 형식 시도 + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso8601Formatter.date(from: dateString) { return date } + + iso8601Formatter.formatOptions = [.withInternetDateTime] + if let date = iso8601Formatter.date(from: dateString) { return date } + + // 나노초 포함 형식 처리 (서버에서 다양한 자릿수로 응답) + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + let formats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", // 9자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", // 8자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", // 7자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", // 6자리 + "yyyy-MM-dd'T'HH:mm:ss.SSS", // 3자리 + "yyyy-MM-dd'T'HH:mm:ss" // 소수점 없음 + ] + + for format in formats { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: dateString) { return date } + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "날짜 파싱 실패: \(dateString)" + ) + } + return decoder + } +} diff --git a/Codive/Shared/Data/Storage/KeychainManager.swift b/Codive/Shared/Data/Storage/KeychainManager.swift new file mode 100644 index 00000000..466283ee --- /dev/null +++ b/Codive/Shared/Data/Storage/KeychainManager.swift @@ -0,0 +1,164 @@ +import Foundation +import Security + +enum KeychainError: Error { + case itemNotFound + case duplicateItem + case invalidData + case unexpectedStatus(OSStatus) + + var localizedDescription: String { + switch self { + case .itemNotFound: + return "토큰을 찾을 수 없습니다." + case .duplicateItem: + return "이미 존재하는 토큰입니다." + case .invalidData: + return "잘못된 데이터 형식입니다." + case .unexpectedStatus(let status): + return "Keychain 오류: \(status)" + } + } +} + +final class KeychainManager { + static let shared = KeychainManager() + + private init() {} + + private let accessTokenKey = "com.codive.accessToken" + private let refreshTokenKey = "com.codive.refreshToken" + + // MARK: - Access Token + + func saveAccessToken(_ token: String) throws { + do { + try save(token, forKey: accessTokenKey) + print("----------------------------------------") + print("🔑 Access Token Saved:") + print(token) + print("----------------------------------------") + } catch { + print("Keychain: Failed to save access token: \(error.localizedDescription)") + throw error + } + } + + func getAccessToken() throws -> String { + do { + let token = try get(forKey: accessTokenKey) + print("Keychain: Access token retrieved successfully.") + return token + } catch { + print("Keychain: Failed to retrieve access token: \(error.localizedDescription)") + throw error + } + } + + func deleteAccessToken() throws { + do { + try delete(forKey: accessTokenKey) + print("Keychain: Access token deleted successfully.") + } catch { + print("Keychain: Failed to delete access token: \(error.localizedDescription)") + throw error + } + } + + // MARK: - Refresh Token + + func saveRefreshToken(_ token: String) throws { + do { + try save(token, forKey: refreshTokenKey) + print("Keychain: Refresh token saved successfully.") + } catch { + print("Keychain: Failed to save refresh token: \(error.localizedDescription)") + throw error + } + } + + func getRefreshToken() throws -> String { + do { + let token = try get(forKey: refreshTokenKey) + print("Keychain: Refresh token retrieved successfully.") + return token + } catch { + print("Keychain: Failed to retrieve refresh token: \(error.localizedDescription)") + throw error + } + } + + func deleteRefreshToken() throws { + try delete(forKey: refreshTokenKey) + } + + // MARK: - Clear All + + func clearAllTokens() throws { + try? deleteAccessToken() + try? deleteRefreshToken() + } + + // MARK: - Private Methods + + private func save(_ value: String, forKey key: String) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.invalidData + } + + // 기존 항목 삭제 (중복 방지) + try? delete(forKey: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + } + + private func get(forKey key: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + throw KeychainError.itemNotFound + } + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + throw KeychainError.invalidData + } + + return value + } + + private func delete(forKey key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } +} diff --git a/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift b/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift index e07c58a5..f1b2bc99 100644 --- a/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift +++ b/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift @@ -15,14 +15,14 @@ struct CustomCategoryBottomSheet: View { /// 현재 선택된 주 카테고리를 외부와 동기화 @Binding var selectedCategory: CategoryItem? - /// 최종 선택 완료 시 호출될 클로저 - let onApply: (CategoryItem, String) -> Void + /// 최종 선택 완료 시 호출될 클로저 (상위카테고리, 하위카테고리) + let onApply: (CategoryItem, SubcategoryItem) -> Void /// 뷰 내부에서만 사용할 선택된 서브 카테고리 상태 - @State private var selectedSubcategory: String? + @State private var selectedSubcategory: SubcategoryItem? /// 초기 선택된 서브 카테고리 (뷰 초기화 시 전달) - let initialSubcategory: String? + let initialSubcategory: SubcategoryItem? var body: some View { VStack(spacing: 0) { @@ -71,14 +71,14 @@ struct CustomCategoryBottomSheet: View { // 오른쪽 (하위 카테고리, 스크롤) ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(selectedCategory?.subcategories ?? [], id: \.self) { sub in + ForEach(selectedCategory?.subcategories ?? []) { sub in Button { selectedSubcategory = sub if let category = selectedCategory { onApply(category, sub) } } label: { - Text(sub) + Text(sub.name) .font(.codive_title3) .foregroundStyle(Color("Grayscale1")) .frame(maxWidth: .infinity, alignment: .center) @@ -138,7 +138,7 @@ struct CustomCategoryBottomSheet: View { allCategories: CategoryConstants.all, selectedCategory: $selectedCategory, onApply: { mainCategory, subCategory in - print("최종 선택 완료: \(mainCategory.name) -> \(subCategory)") + print("최종 선택 완료: \(mainCategory.name) -> \(subCategory.name)") }, initialSubcategory: nil ) diff --git a/Codive/Shared/DesignSystem/Views/CustomClothCard.swift b/Codive/Shared/DesignSystem/Views/CustomClothCard.swift index 84421ef1..77442752 100644 --- a/Codive/Shared/DesignSystem/Views/CustomClothCard.swift +++ b/Codive/Shared/DesignSystem/Views/CustomClothCard.swift @@ -8,16 +8,35 @@ import SwiftUI struct CustomClothCard: View { - let imageName: String + let imageName: String? + let imageUrl: String? let brand: String let title: String - + // 편집 모드 관련 프로퍼티 추가 var isEditMode: Bool = false var isSelected: Bool = false - + var action: () -> Void = {} + init( + imageName: String? = nil, + imageUrl: String? = nil, + brand: String, + title: String, + isEditMode: Bool = false, + isSelected: Bool = false, + action: @escaping () -> Void = {} + ) { + self.imageName = imageName + self.imageUrl = imageUrl + self.brand = brand + self.title = title + self.isEditMode = isEditMode + self.isSelected = isSelected + self.action = action + } + var body: some View { Button(action: { action() }, label: { VStack(alignment: .leading, spacing: 0) { @@ -25,18 +44,16 @@ struct CustomClothCard: View { // 1. 상품 이미지 ZStack { Color.Codive.grayscale7 - - Image(imageName) - .resizable() - .aspectRatio(contentMode: .fill) - + + imageContent + // 선택 시 회색 오버레이 (삭제 선택.png 참고) if isEditMode && isSelected { Color.black.opacity(0.1) } } .clipped() - + // 2. 편집 모드일 때 나타나는 선택 원 if isEditMode { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") @@ -73,6 +90,34 @@ struct CustomClothCard: View { }) .buttonStyle(.plain) } + + @ViewBuilder + private var imageContent: some View { + if let imageUrl = imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + @unknown default: + EmptyView() + } + } + } else if let imageName = imageName, !imageName.isEmpty { + Image(imageName) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + } + } } struct ClothGridView: View { diff --git a/Codive/Shared/DesignSystem/Views/SafariView.swift b/Codive/Shared/DesignSystem/Views/SafariView.swift new file mode 100644 index 00000000..2243ab7c --- /dev/null +++ b/Codive/Shared/DesignSystem/Views/SafariView.swift @@ -0,0 +1,21 @@ +// +// SafariView.swift +// Codive +// +// Created by Hrepay on 2026/01/11. +// + +import SwiftUI +import SafariServices + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { + let safariViewController = SFSafariViewController(url: url) + return safariViewController + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { + } +} diff --git a/Project.swift b/Project.swift index f3fa272c..167f9397 100644 --- a/Project.swift +++ b/Project.swift @@ -91,10 +91,15 @@ let project = Project( // 카카오 SDK 설정 "KAKAO_APP_KEY": "$(KAKAO_APP_KEY)", + "KAKAO_AUTH_URL": "$(KAKAO_AUTH_URL)", "CFBundleURLTypes": [ [ "CFBundleURLName": "KAKAO", "CFBundleURLSchemes": ["kakao$(KAKAO_APP_KEY)"] + ], + [ + "CFBundleURLName": "CODIVE", + "CFBundleURLSchemes": ["codive"] ] ], "LSApplicationQueriesSchemes": [ @@ -123,7 +128,10 @@ let project = Project( .external(name: "KakaoSDKUser"), // 네트워킹 - .external(name: "Moya") + .external(name: "Moya"), + + // CodiveAPI + .external(name: "CodiveAPI") ], settings: .settings( base: [ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 37259c97..857e0670 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,13 +1,22 @@ { - "originHash" : "6cbaf0561fc687651adcac45da73a2ab8140456a303eecb6dd360ec98a778fdb", + "originHash" : "003f4af15c02bf1cca26de772279423462b20465025ec4af00cf1d13daadd66c", "pins" : [ { "identity" : "alamofire", "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" + "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", + "version" : "5.11.0" + } + }, + { + "identity" : "codiveapi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Clokey-dev/CodiveAPI", + "state" : { + "branch" : "main", + "revision" : "124c84772a8a0199b93c4ac81f2b98dc926f6430" } }, { @@ -15,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "f17e30773062d9df4c8e0e211da9ac22dfb2e5c1", - "version" : "2.24.6" + "revision" : "1a2b530921ab9d1f4385ced84109b7a86d42cff8", + "version" : "2.27.1" } }, { @@ -42,8 +51,44 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ReactiveX/RxSwift.git", "state" : { - "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", - "version" : "6.9.0" + "revision" : "5004a18539bd68905c5939aa893075f578f4f03d", + "version" : "6.9.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" } } ], diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 05f6e448..2da229cb 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -8,7 +8,12 @@ import PackageDescription // Customize the product types for specific package product // Default is .staticFramework // productTypes: ["Alamofire": .framework,] - productTypes: [:] + productTypes: [ + "KakaoSDKUser": .framework, + "KakaoSDKAuth": .framework, + "KakaoSDKCommon": .framework, + "Alamofire": .framework, + ] ) #endif @@ -17,6 +22,7 @@ let package = Package( dependencies: [ // 카카오 SDK .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.5"), - .package(url: "https://github.com/Moya/Moya.git", from: "15.0.0") + .package(url: "https://github.com/Moya/Moya.git", from: "15.0.0"), + .package(url: "https://github.com/Clokey-dev/CodiveAPI", branch: "main") ] )