diff --git a/Codive/DIContainer/ClosetDIContainer.swift b/Codive/DIContainer/ClosetDIContainer.swift index 913df8ce..54fcc078 100644 --- a/Codive/DIContainer/ClosetDIContainer.swift +++ b/Codive/DIContainer/ClosetDIContainer.swift @@ -51,6 +51,10 @@ final class ClosetDIContainer { func makeDeleteClothItemsUseCase() -> DeleteClothItemsUseCase { return DeleteClothItemsUseCase(repository: clothRepository) } + + func makeFetchMyLookBookListUseCase() -> FetchMyLookBookListUseCase { + return FetchMyLookBookListUseCase(repository: clothRepository) + } // MARK: - ViewModels func makeMyClosetViewModel() -> MyClosetViewModel { @@ -67,6 +71,13 @@ final class ClosetDIContainer { fetchMyClosetClothItemsUseCase: makeFetchMyClosetClothItemsUseCase() ) } + + func makeMyLookBookSectionViewModel() -> MyLookbookSectionViewModel { + return MyLookbookSectionViewModel( + navigationRouter: navigationRouter, + fetchMyLookBookListUseCase: makeFetchMyLookBookListUseCase() + ) + } func makeClothDetailViewModel(cloth: Cloth) -> ClothDetailViewModel { return ClothDetailViewModel( diff --git a/Codive/DIContainer/HomeDIContainer.swift b/Codive/DIContainer/HomeDIContainer.swift index 7d34479c..c77def5c 100644 --- a/Codive/DIContainer/HomeDIContainer.swift +++ b/Codive/DIContainer/HomeDIContainer.swift @@ -98,6 +98,7 @@ final class HomeDIContainer { return CodiBoardViewModel( navigationRouter: navigationRouter, codiBoardUseCase: makeCodiBoardUseCase(), + todayCodiUseCase: makeTodayCodiUseCase(), homeViewModel: homeViewModel ) } diff --git a/Codive/DIContainer/LookBookDIContainer.swift b/Codive/DIContainer/LookBookDIContainer.swift index 4f5090e9..f9b51f98 100644 --- a/Codive/DIContainer/LookBookDIContainer.swift +++ b/Codive/DIContainer/LookBookDIContainer.swift @@ -98,21 +98,21 @@ final class LookBookDIContainer { // MARK: - Add Codi func makeAddCodiViewModel( - coordinateId: Int64 + lookBookId: Int64 ) -> AddCodiViewModel { return AddCodiViewModel( navigationRouter: navigationRouter, codiUseCase: makeCodiUseCase(), - coordinateId: coordinateId + lookBookId: lookBookId ) } func makeAddCodiView( - coordinateId: Int64 + lookBookId: Int64 ) -> AddCodiView { return AddCodiView( viewModel: makeAddCodiViewModel( - coordinateId: coordinateId + lookBookId: lookBookId ) ) } diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift index 3a31be76..27319683 100644 --- a/Codive/DIContainer/ProfileDIContainer.swift +++ b/Codive/DIContainer/ProfileDIContainer.swift @@ -57,13 +57,18 @@ final class ProfileDIContainer { func makeFetchMonthlyHistoryUseCase() -> FetchMonthlyHistoryUseCase { return FetchMonthlyHistoryUseCase(historyRepository: historyRepository) } + + func makeFetchMyFavoriteLookBookUseCase() -> FetchMyFavoriteLookBookUseCase { + return FetchMyFavoriteLookBookUseCase(repository: profileRepository) + } // MARK: - ViewModels private lazy var profileViewModel: ProfileViewModel = { return ProfileViewModel( navigationRouter: navigationRouter, fetchMyProfileUseCase: makeFetchMyProfileUseCase(), - fetchMonthlyHistoryUseCase: makeFetchMonthlyHistoryUseCase() + fetchMonthlyHistoryUseCase: makeFetchMonthlyHistoryUseCase(), + fetchMyFavoriteLookBookUseCase: makeFetchMyFavoriteLookBookUseCase() ) }() @@ -90,6 +95,13 @@ final class ProfileDIContainer { func makeOtherProfileViewModel() -> OtherProfileViewModel { return OtherProfileViewModel(navigationRouter: navigationRouter) } + + func makeFavoriteCodiViewModel() -> FavoriteCodiViewModel { + return FavoriteCodiViewModel( + navigationRouter: navigationRouter, + fetchMyFavoriteLookBookUseCase: makeFetchMyFavoriteLookBookUseCase() + ) + } // MARK: - Views func makeProfileView() -> ProfileView { @@ -109,4 +121,12 @@ final class ProfileDIContainer { navigationRouter: navigationRouter ) } + + func makeFavoriteCodiView(showHeart: Bool) -> FavoriteCodiView { + return FavoriteCodiView( + showHeart: showHeart, + viewModel: makeFavoriteCodiViewModel(), + navigationRouter: navigationRouter + ) + } } diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index e5ad36f0..fa48de7c 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -20,6 +20,13 @@ protocol ClothAPIServiceProtocol { func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws func deleteCloth(clothId: Int64) async throws + + /// 룩북 전체 조회 + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> LookBookListResponseDTO } // MARK: - Supporting Types @@ -279,6 +286,37 @@ extension ClothAPIService { } } +extension ClothAPIService { + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload = .DESC + ) async throws -> LookBookListResponseDTO { + + let input = Operations.LookBook_getLookBooks.Input( + query: .init(lastLookBookId: lastLookBookId, size: size, direction: direction) + ) + + let response = try await client.LookBook_getLookBooks(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.BaseResponseSliceResponseLookBookListResponse.self, from: data) + + let content: [LookBookListResponseItem] = decoded.result?.content?.compactMap { item -> LookBookListResponseItem? in + guard let lookBookId = item.lookBookId else { return nil } + return LookBookListResponseItem(lookBookId: lookBookId, lookBookName: item.lookBookName ?? "", imageUrl: item.imageUrl ?? "", count: item.count ?? 0) + } ?? [] + + return LookBookListResponseDTO(content: content, isLast: decoded.result?.isLast ?? true) + + case .undocumented(statusCode: let code, _): + throw LookBookAPIError.serverError(statusCode: code, message: "룩북 목록 조회 실패") + } + } +} + // MARK: - Private Helpers private extension ClothAPIService { diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index 2da42e8a..8e75e80f 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -6,6 +6,7 @@ // import Foundation +import CodiveAPI // MARK: - ClothDataSource Protocol @@ -40,6 +41,13 @@ protocol ClothDataSource { func deleteCloth(clothId: Int) async throws func deleteClothItems(_ clothIds: [Int]) async throws + + /// 룩북 전체 조회 + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> LookBookListResponseDTO } // MARK: - DefaultClothDataSource @@ -195,6 +203,19 @@ final class DefaultClothDataSource: ClothDataSource { func deleteCloth(clothId: Int) async throws { try await apiService.deleteCloth(clothId: Int64(clothId)) } + + /// 룩북 전체 리스트 조회 + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> LookBookListResponseDTO { + return try await apiService.fetchLookBookList( + lastLookBookId: lastLookBookId, + size: size, + direction: direction + ) + } } // MARK: - ClothDataSourceError diff --git a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift index af2baee9..d87b85c6 100644 --- a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift @@ -6,6 +6,7 @@ // import Foundation +import CodiveAPI // MARK: - ClothRepositoryImpl @@ -90,4 +91,22 @@ final class ClothRepositoryImpl: ClothRepository { func deleteCloth(clothId: Int) async throws { try await dataSource.deleteCloth(clothId: clothId) } + + // 룩북 조회 + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> (content: [LookBookEntity], isLast: Bool) { + let dto = try await dataSource.fetchLookBookList( + lastLookBookId: lastLookBookId, + size: size, + direction: direction + ) + + return ( + content: dto.content.map { $0.toEntity() }, + isLast: dto.isLast + ) + } } diff --git a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift index 798ba373..cfec1f1c 100644 --- a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift +++ b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift @@ -6,6 +6,7 @@ // import Foundation +import CodiveAPI // MARK: - ClothRepository protocol ClothRepository { @@ -38,4 +39,11 @@ protocol ClothRepository { func deleteCloth(clothId: Int) async throws func deleteClothItems(_ clothIds: [Int]) async throws + + /// 룩북 조회 + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> (content: [LookBookEntity], isLast: Bool) } diff --git a/Codive/Features/Closet/Domain/UseCases/FetchMyLookBookListUseCase.swift b/Codive/Features/Closet/Domain/UseCases/FetchMyLookBookListUseCase.swift new file mode 100644 index 00000000..9ec81f91 --- /dev/null +++ b/Codive/Features/Closet/Domain/UseCases/FetchMyLookBookListUseCase.swift @@ -0,0 +1,34 @@ +// +// FetchMyLookBookListUseCase.swift +// Codive +// +// Created by 한금준 on 2/4/26. +// + +import CodiveAPI + +// MARK: - FetchMyLookBookListUseCase +final class FetchMyLookBookListUseCase { + + // MARK: - Properties + private let repository: ClothRepository + + // MARK: - Initializer + init(repository: ClothRepository) { + self.repository = repository + } + + // MARK: - Methods + /// 룩북 목록 조회 + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> (content: [LookBookEntity], isLast: Bool) { + return try await repository.fetchLookBookList( + lastLookBookId: lastLookBookId, + size: size, + direction: direction + ) + } +} diff --git a/Codive/Features/Closet/Presentation/View/main/ClosetView.swift b/Codive/Features/Closet/Presentation/View/main/ClosetView.swift index 26cf2cc5..0e7f9c71 100644 --- a/Codive/Features/Closet/Presentation/View/main/ClosetView.swift +++ b/Codive/Features/Closet/Presentation/View/main/ClosetView.swift @@ -28,7 +28,9 @@ struct ClosetView: View { WardrobeReportView() - MyLookbookSectionView() + MyLookbookSectionView( + viewModel: closetDIContainer.makeMyLookBookSectionViewModel() + ) Spacer(minLength: 45) } } diff --git a/Codive/Features/Closet/Presentation/View/main/MyLookbookSectionView.swift b/Codive/Features/Closet/Presentation/View/main/MyLookbookSectionView.swift index 26438c8a..633f49da 100644 --- a/Codive/Features/Closet/Presentation/View/main/MyLookbookSectionView.swift +++ b/Codive/Features/Closet/Presentation/View/main/MyLookbookSectionView.swift @@ -9,19 +9,13 @@ import SwiftUI struct MyLookbookSectionView: View { - // MARK: - Data Model - struct LookbookItem: Identifiable { - let id = UUID() - let title: String + @StateObject private var viewModel: MyLookbookSectionViewModel + + init(viewModel: MyLookbookSectionViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) } // MARK: - Properties - // 임시 데이터 - let lookbooks: [LookbookItem] = [ - LookbookItem(title: "벚꽃 데이트룩"), - LookbookItem(title: "스페인 여행"), - LookbookItem(title: "독서실룩") - ] // 그리드 레이아웃 설정 let columns = [ @@ -40,7 +34,7 @@ struct MyLookbookSectionView: View { Spacer() Button( - action: { print("더보기") }, + action: { viewModel.navigateToLookBook() }, label: { HStack(spacing: 2) { Text("더보기") @@ -54,23 +48,26 @@ struct MyLookbookSectionView: View { .padding(.horizontal, 20) LazyVGrid(columns: columns, spacing: 20) { - // 룩북 개수가 4개 미만일 때만 '룩북 만들기' 버튼을 표시 - if lookbooks.count < 4 { + if viewModel.lookBookList.count < 4 { AddLookbookButton { - print("룩북 만들기 클릭") - + viewModel.navigateToAddLookbook() } } + + let displayCount = viewModel.lookBookList.count < 4 ? 3 : 4 - // 버튼 유무에 따라 표시할 카드 개수 조절 - let displayCount = lookbooks.count < 4 ? 3 : 4 - ForEach(lookbooks.prefix(displayCount)) { item in - LookbookCardView(item: item) + ForEach(viewModel.lookBookList.prefix(displayCount)) { item in + LookbookCardView(item: item) { selectedItem in + viewModel.navigateToSpecificLookbook(lookBook: selectedItem) + } } } .padding(.horizontal, 20) } .padding(.vertical, 20) + .onAppear { + viewModel.fetchMyLookBooks() + } } } @@ -115,20 +112,28 @@ struct AddLookbookButton: View { struct LookbookCardView: View { // MARK: - Properties - let item: MyLookbookSectionView.LookbookItem + let item: LookBookEntity + let onTap: (LookBookEntity) -> Void // MARK: - Body var body: some View { VStack(alignment: .leading, spacing: 8) { ZStack { + // 배경색 Rectangle() .fill(Color.Codive.grayscale6) - - Image(systemName: "tshirt") - .resizable() - .scaledToFit() - .frame(width: 50) - .foregroundStyle(Color.Codive.grayscale4) + + AsyncImage(url: URL(string: item.imageUrl)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Image(systemName: "tshirt") + .resizable() + .scaledToFit() + .frame(width: 50) + .foregroundStyle(Color.Codive.grayscale4) + } } .aspectRatio(1.0, contentMode: .fit) .cornerRadius(12) @@ -137,16 +142,15 @@ struct LookbookCardView: View { RoundedRectangle(cornerRadius: 12) .stroke(Color.Codive.grayscale5, lineWidth: 1) ) - - Text(item.title) + + Text(item.lookbookName) .font(.codive_body2_medium) .foregroundStyle(Color.Codive.grayscale1) .lineLimit(1) .padding(.leading, 2) } + .onTapGesture { + onTap(item) + } } } - -#Preview { - MyLookbookSectionView() -} diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyLookbookSectionViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyLookbookSectionViewModel.swift new file mode 100644 index 00000000..ee1a6602 --- /dev/null +++ b/Codive/Features/Closet/Presentation/ViewModel/MyLookbookSectionViewModel.swift @@ -0,0 +1,61 @@ +// +// MyLookbookSectionViewModel.swift +// Codive +// +// Created by 한금준 on 2/4/26. +// + +import Foundation + +@MainActor +final class MyLookbookSectionViewModel: ObservableObject { + // MARK: - Private Properties + private let navigationRouter: NavigationRouter + private let fetchMyLookBookListUseCase: FetchMyLookBookListUseCase + + @Published var lookBookList: [LookBookEntity] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + init( + navigationRouter: NavigationRouter, + fetchMyLookBookListUseCase: FetchMyLookBookListUseCase + ) { + self.navigationRouter = navigationRouter + self.fetchMyLookBookListUseCase = fetchMyLookBookListUseCase + } + + func fetchMyLookBooks() { + isLoading = true + errorMessage = nil + + Task { + do { + let result = try await fetchMyLookBookListUseCase.fetchLookBookList(lastLookBookId: nil, size: 10, direction: .DESC) + self.lookBookList = result.content + } catch { + self.errorMessage = "데이터 로드에 실패했습니다: \(error.localizedDescription)" + } + isLoading = false + } + } + + /// 룩북으로 이동 + func navigateToLookBook() { + navigationRouter.navigate(to: .lookbook) + } + + func navigateToAddLookbook() { + LookBookEventManager.shared.shouldShowAddDialog.send(true) + navigationRouter.navigate(to: .lookbook) + } + + func navigateToSpecificLookbook(lookBook: LookBookEntity) { + navigationRouter.navigate( + to: .specificLookbook( + lookbookId: lookBook.lookBookId, + name: lookBook.lookbookName + ) + ) + } +} diff --git a/Codive/Features/Home/Data/DTOs/HomeCoordinateDTO.swift b/Codive/Features/Home/Data/DTOs/HomeCoordinateDTO.swift index d09f3b43..3020c413 100644 --- a/Codive/Features/Home/Data/DTOs/HomeCoordinateDTO.swift +++ b/Codive/Features/Home/Data/DTOs/HomeCoordinateDTO.swift @@ -21,3 +21,24 @@ struct CreateTodayCoordinateResponseDTO { TodayCoordinateEntity(coordinateId: coordinateId) } } + +struct FetchTodayCoordinatePreviewResponseDTO { + let coordinateId: Int64 + let imageUrl: String + let date: String +} + +struct FetchTodayCoordinateDetailsResponseDTO { + let coordinateClothId: Int64 + let locationX: Double + let locationY: Double + let ratio: Double + let degree: Double + let order: Int32 + let clothId: Int64 + let imageUrl: String + let brand: String + let name: String + let category: String + let parentCategory: String +} diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index c1441151..56b490a2 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -26,7 +26,9 @@ protocol HomeDatasourceProtocol { func createTodayCoordinate(request: CreateTodayCoordinateRequestDTO) async throws -> CreateTodayCoordinateResponseDTO /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO + + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] /// 룩북 전체 조회 func fetchLookBookList( @@ -34,6 +36,12 @@ protocol HomeDatasourceProtocol { size: Int32, direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload ) async throws -> LookBookListResponseDTO + + /// 코디 수정 + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws + + /// 이전 일일 코디로 자동 생성 + func createAutoDailyCoordinate(request: CreateAutoDailyCoordinateAPIRequestDTO) async throws -> CreateAutoDailyCoordinateAPIResponseDTO } final class HomeDatasource: HomeDatasourceProtocol { @@ -171,8 +179,12 @@ final class HomeDatasource: HomeDatasourceProtocol { } /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] { - return try await apiService.fetchTodayCoordinateClothes() + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO { + return try await apiService.fetchTodayCoordinatePreview() + } + + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] { + return try await apiService.fetchTodayCoordinateDetails() } /// 룩북 전체 리스트 조회 @@ -187,29 +199,61 @@ final class HomeDatasource: HomeDatasourceProtocol { direction: direction ) } -} - -extension HomeDatasource { - // 오늘의 코디 추가하기 - func createTodayDailyCodi(_ entity: TodayDailyCodi) async throws { - - let requestDTO = CodiCoordinateRequestDTO( - coordinateImageUrl: entity.coordinateImageUrl, - Payload: entity.payloads.map { - CodiCoordinatePayloadDTO( - clothId: Int64($0.clothId), - locationX: $0.locationX, - locationY: $0.locationY, - ratio: $0.ratio, - degree: Double($0.degree), - order: $0.order - ) - } + + /// 코디 수정 + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws { + try await apiService.patchUpdateCoordinates(coordinateId: coordinateId, request: request) + } + + /// 코디 이미지를 S3에 업로드하고 최종 URL을 반환 + func uploadCodiImage(jpgData: Data) async throws -> String { + let presignedUrlInfos = try await apiService.getPresignedUrls(for: [jpgData]) + + guard let urlInfo = presignedUrlInfos.first else { + throw LookBookAPIError.uploadFailed(message: "Presigned URL 발급 실패") + } + + try await uploadImageToS3( + presignedUrl: urlInfo.presignedUrl, + imageData: jpgData, + md5Hash: urlInfo.md5Hash ) - try await saveCodiCoordinate(requestDTO) + return urlInfo.finalUrl } + /// 이전 일일 코디로 자동 생성 + func createAutoDailyCoordinate(request: CreateAutoDailyCoordinateAPIRequestDTO) async throws -> CreateAutoDailyCoordinateAPIResponseDTO { + return try await apiService.createAutoDailyCoordinate(request: request) + } + + private func uploadImageToS3( + presignedUrl: String, + imageData: Data, + md5Hash: String + ) async throws { + guard let url = URL(string: presignedUrl) else { + throw LookBookAPIError.invalidUrl + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.setValue(md5Hash, forHTTPHeaderField: "Content-MD5") + request.httpBody = imageData + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw LookBookAPIError.uploadFailed(message: "S3 업로드 실패 (Status: \((response as? HTTPURLResponse)?.statusCode ?? -1))") + } + + print("✅ S3 이미지 업로드 성공") + } +} + +extension HomeDatasource { // MARK: - Categorory 수정 뷰 관련 /// 카테고리 별 개수 func loadCategories() -> [CategoryEntity] { @@ -219,7 +263,7 @@ extension HomeDatasource { } return [ - CategoryEntity(id: 1, title: "상의", itemCount: 2), + CategoryEntity(id: 1, title: "상의", itemCount: 1), CategoryEntity(id: 2, title: "바지", itemCount: 1), CategoryEntity(id: 3, title: "스커트", itemCount: 0), CategoryEntity(id: 4, title: "아우터", itemCount: 0), @@ -237,73 +281,7 @@ extension HomeDatasource { print("저장 완료:") categories.forEach { print("\($0.id): \($0.title): \($0.itemCount)") } } - - // MARK: - 코디보드 - // 코디 추가하기 - func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { - try await Task.sleep(nanoseconds: 500_000_000) - - for (index, item) in request.Payload.enumerated() { - print(""" - [Item \(index)] - - clothId: \(item.clothId) - - position: (\(item.locationX), \(item.locationY)) - - ratio(scale): \(item.ratio) - - degree: \(item.degree) - - order: \(item.order) - """) - } - } - - // 코디보드 옷 불러오기 - func loadInitialImages() -> [DraggableImageEntity] { - return [ -// DraggableImageEntity(id: 1, name: "image1", position: CGPoint(x: 80, y: 80), scale: 1.0, rotationAngle: 0.0), -// DraggableImageEntity(id: 2, name: "image2", position: CGPoint(x: 160, y: 120), scale: 1.0, rotationAngle: 0.0), -// DraggableImageEntity(id: 3, name: "image3", position: CGPoint(x: 240, y: 160), scale: 1.0, rotationAngle: 0.0), -// DraggableImageEntity(id: 4, name: "image4", position: CGPoint(x: 120, y: 240), scale: 1.0, rotationAngle: 0.0), -// DraggableImageEntity(id: 5, name: "image5", position: CGPoint(x: 200, y: 280), scale: 1.0, rotationAngle: 0.0), -// DraggableImageEntity(id: 6, name: "image6", position: CGPoint(x: 250, y: 240), scale: 1.0, rotationAngle: 0.0) - ] - } // MARK: - 코디가 있는 경우의 Home 관련 - // 코디 불러오기 - func loadDummyCodiItems() -> [CodiItemEntity] { - return [ - CodiItemEntity( - id: 1, - imageName: "image1", - clothName: "시계", - brandName: "apple", - description: "사계절 착용 가능한 시계", - x: 300, - y: 100, - width: 70, - height: 70 - ), - CodiItemEntity( - id: 2, - imageName: "image4", - clothName: "체크 셔츠", - brandName: "Polo", - description: "사계절 착용 가능한 셔츠", - x: 100, - y: 100, - width: 70, - height: 70 - ), - CodiItemEntity( - id: 3, - imageName: "image3", - clothName: "와이드 치노 팬츠", - brandName: "Basic Concept", - description: "사계절 착용 가능한 면 바지", - x: 300, - y: 200, - width: 100, - height: 100 - ) - ] - } + } diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index 284a34b8..0e44d978 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -58,9 +58,12 @@ final class HomeRepositoryImpl: HomeRepository { } /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [TodayCoordinateClothEntity] { - let dtos = try await dataSource.fetchTodayCoordinateClothes() - return dtos.map { $0.toEntity() } + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO { + return try await dataSource.fetchTodayCoordinatePreview() + } + + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] { + return try await dataSource.fetchTodayCoordinateDetails() } // 룩북 조회 @@ -80,28 +83,28 @@ final class HomeRepositoryImpl: HomeRepository { isLast: dto.isLast ) } -} - -extension HomeRepositoryImpl { - func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws { - try await dataSource.createTodayDailyCodi(codi) - } - - // MARK: - 코디보드 - func fetchInitialImages() -> [DraggableImageEntity] { - dataSource.loadInitialImages() + /// 코디 수정 + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws { + try await dataSource.patchUpdateCoordinates(coordinateId: coordinateId, request: request) } - func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { - try await dataSource.saveCodiCoordinate(request) + /// 이전 일일 코디로 자동 생성 + func createAutoDailyCoordinate( + request: CreateAutoDailyCoordinateAPIRequestDTO + ) async throws -> AutoDailyCoordinateEntity { + let dto = try await dataSource.createAutoDailyCoordinate(request: request) + return dto.toEntity() } - // MARK: - 코디가 있는 경우의 Home 관련 - - func fetchCodiItems() -> [CodiItemEntity] { - dataSource.loadDummyCodiItems() + /// 이미지 업로드 + func uploadCodiImage(jpgData: Data) async throws -> String { + return try await dataSource.uploadCodiImage(jpgData: jpgData) } +} + +extension HomeRepositoryImpl { + // MARK: - 코디가 있는 경우의 Home 관련 func getToday() -> DateEntity { dataSource.fetchToday() diff --git a/Codive/Features/Home/Data/Services/HomeAPIService.swift b/Codive/Features/Home/Data/Services/HomeAPIService.swift index 1e84e137..d65dd8a6 100644 --- a/Codive/Features/Home/Data/Services/HomeAPIService.swift +++ b/Codive/Features/Home/Data/Services/HomeAPIService.swift @@ -23,7 +23,9 @@ protocol HomeAPIServiceProtocol { func createTodayCoordinate(request: CreateTodayCoordinateRequestDTO) async throws -> CreateTodayCoordinateResponseDTO /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO + + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] /// 룩북 전체 조회 func fetchLookBookList( @@ -31,13 +33,21 @@ protocol HomeAPIServiceProtocol { size: Int32, direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload ) async throws -> LookBookListResponseDTO + + /// 코디 수정 + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws + + func createAutoDailyCoordinate(request: CreateAutoDailyCoordinateAPIRequestDTO) async throws -> CreateAutoDailyCoordinateAPIResponseDTO + + /// 이미지 url 생성 + func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] } final class HomeAPIService: HomeAPIServiceProtocol { - + private let client: Client private let jsonDecoder: JSONDecoder - + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] @@ -50,8 +60,8 @@ extension HomeAPIService { func fetchRecommendCategoryCloth(lastClothId: Int64?, size: Int32, categoryId: Int64, season: [Season]) async throws -> HomeCategoryResponseDTO { guard let firstSeason = season.first else { - throw HomeAPIError.invalidResponse - } + throw HomeAPIError.invalidResponse + } let seasonPayload = mapSeasonToQueryParam(firstSeason) let input = Operations.Cloth_recommendCategoryClothes.Input( @@ -64,11 +74,6 @@ extension HomeAPIService { case .ok(let okResponse): let data = try await Data(collecting: okResponse.body.any, upTo: .max) -#if DEBUG - print("📦 Raw JSON Response:") - print(String(data: data, encoding: .utf8) ?? "❌ JSON 변환 실패") -#endif - let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseClothRecommendListResponse.self, from: data) let content: [HomeCategoryResponseItem] = decoded.result?.content?.map { item -> HomeCategoryResponseItem in @@ -82,26 +87,59 @@ extension HomeAPIService { } } - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] { - let input = Operations.Coordinate_getTodayDailyCoordinateClothes.Input() + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO { + let input = Operations.Coordinate_getTodayCoordinatePreview.Input() - let response = try await client.Coordinate_getTodayDailyCoordinateClothes(input) + let response = try await client.Coordinate_getTodayCoordinatePreview(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.BaseResponseListDailyCoordinateClothResponse.self, from: data) + + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseDailyCoordinatePreviewResponse.self, from: data) + + guard let item = decoded.result else { + throw LookBookAPIError.invalidResponse + } + + return FetchTodayCoordinatePreviewResponseDTO( + coordinateId: item.coordinateId ?? 0, + imageUrl: item.imageUrl ?? "", + date: item.date ?? "" + ) + + case .undocumented(statusCode: let code, _): + throw HomeAPIError.serverError(statusCode: code, message: "오늘의 코디 preview 조회 실패") + } + } + + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] { + let input = Operations.Coordinate_getTodayCoordinateDetails.Input() + + let response = try await client.Coordinate_getTodayCoordinateDetails(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.BaseResponseListCoordinateDetailsListResponse.self, from: data) let items = decoded.result ?? [] - + return items.map { item in - GetTodayCoordinateClothResponseDTO( + FetchTodayCoordinateDetailsResponseDTO( + coordinateClothId: item.coordinateClothId ?? 0, + locationX: item.locationX ?? 0, + locationY: item.locationY ?? 0, + ratio: item.ratio ?? 0, + degree: item.degree ?? 0, + order: item.order ?? 0, + clothId: item.clothId ?? 0, imageUrl: item.imageUrl ?? "", brand: item.brand ?? "", name: item.name ?? "", category: item.category ?? "", - parentCategory: item.parentCategory ?? "" + parentCategory: item.parentCategory ?? "", ) } @@ -145,10 +183,10 @@ extension HomeAPIService { let requestBody = Components.Schemas.TemperatureNotificationRequest( temperature: request.temperature ) - + let input = Operations.sendTemperatureNotification.Input(body: .json(requestBody)) let response = try await client.sendTemperatureNotification(input) - + switch response { case .ok: return @@ -175,7 +213,7 @@ extension HomeAPIService { ) let input = Operations.Coordinate_createDailyCoordinate.Input(body: .json(requestBody)) let response = try await client.Coordinate_createDailyCoordinate(input) - + switch response { case .ok(let okResponse): let data = try await Data(collecting: okResponse.body.any, upTo: .max) @@ -183,20 +221,110 @@ extension HomeAPIService { Components.Schemas.BaseResponseCoordinateCreateResponse.self, from: data ) - + guard let coordinateId = decoded.result?.coordinateId else { throw HomeAPIError.invalidResponse } return CreateTodayCoordinateResponseDTO(coordinateId: coordinateId) - + case .undocumented(statusCode: let code, _): throw HomeAPIError.serverError(statusCode: code, message: "오늘의 코디 생성 실패") } } + + 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.ClothAi_getClothUploadPresignedUrl.Input(body: .json(requestBody)) + let response = try await client.ClothAi_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 LookBookAPIError.serverError(statusCode: code, message: "Presigned URL 발급 실패") + } + } + + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws { + let requestBody = Components.Schemas.CoordinateUpdateRequest( + coordinateImageUrl: request.coordinateImageUrl, + name: request.name, + memo: request.memo, + payloads: request.payloads?.map { + Components.Schemas.CoordinateUpdateRequestPayload( + clothId: $0.clothId, + locationX: $0.locationX, + locationY: $0.locationY, + ratio: $0.ratio, + degree: $0.degree, + order: Int32($0.order) + ) + } + ) + + let input = Operations.Coordinate_updateCoordinate.Input( + path: .init(coordinateId: coordinateId), + body: .json(requestBody) + ) + let response = try await client.Coordinate_updateCoordinate(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw LookBookAPIError.serverError(statusCode: code, message: "코디 수정 실패") + } + } + + func createAutoDailyCoordinate(request: CreateAutoDailyCoordinateAPIRequestDTO) async throws -> CreateAutoDailyCoordinateAPIResponseDTO { + let requestBody = Components.Schemas.CoordinateAutoCreateRequest( + name: request.name, + memo: request.memo, + dailyCoordinateId: request.dailyCoordinateId, + lookBookId: request.lookBookId + ) + let input = Operations.Coordinate_createCoordinateAuto.Input(body: .json(requestBody)) + let response = try await client.Coordinate_createCoordinateAuto(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.BaseResponseCoordinateCreateResponse.self, + from: data + ) + + guard let coordinateId = decoded.result?.coordinateId else { + throw LookBookAPIError.invalidResponse + } + return CreateAutoDailyCoordinateAPIResponseDTO(coordinateId: coordinateId) + + case .undocumented(statusCode: let code, _): + throw LookBookAPIError.serverError(statusCode: code, message: "이전 일일 코디 자동 생성 실패") + } + } } private extension HomeAPIService { - + func mapSeasonToQueryParam( _ season: Season ) -> Operations.Cloth_recommendCategoryClothes.Input.Query.seasonPayload { @@ -207,6 +335,20 @@ private extension HomeAPIService { case .winter: return .WINTER } } + + 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 + } } // MARK: - ClothAPIError @@ -218,7 +360,7 @@ enum HomeAPIError: LocalizedError { case s3UploadFailed(statusCode: Int) case noClothIdsReturned case serverError(statusCode: Int, message: String) - + var errorDescription: String? { switch self { case .presignedUrlMismatch: diff --git a/Codive/Features/Home/Domain/Entities/HomeCodiEntity.swift b/Codive/Features/Home/Domain/Entities/HomeCodiEntity.swift index e26aa5c3..4af2bfaa 100644 --- a/Codive/Features/Home/Domain/Entities/HomeCodiEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeCodiEntity.swift @@ -13,16 +13,19 @@ struct TodayCoordinateEntity { } // MARK: - Codi Item -struct CodiItemEntity: Identifiable { - let id: Int64 - let imageName: String - let clothName: String - let brandName: String - let description: String - let x: CGFloat - let y: CGFloat - let width: CGFloat - let height: CGFloat +struct CodiItemEntity { + let coordinateClothId: Int64 + let locationX: Double + let locationY: Double + let ratio: Double + let degree: Double + let order: Int32 + let clothId: Int64 + let imageUrl: String + let brand: String + let name: String + let category: String + let parentCategory: String } struct TodayDailyCodi { @@ -54,15 +57,6 @@ struct CodiCoordinatePayloadDTO: Codable { let order: Int } -// MARK: - Draggable Image -//struct DraggableImageEntity: Identifiable, Hashable { -// let id: Int -// let name: String -// var position: CGPoint -// var scale: CGFloat -// var rotationAngle: Double -//// let imageURL: String? = nil -//} protocol DraggableImageProtocol: Identifiable { var id: Int64 { get } var imageUrl: String { get } @@ -80,3 +74,16 @@ struct DraggableImageEntity: DraggableImageProtocol, Equatable, Hashable { var scale: CGFloat var rotation: Double } + +struct TodayCodiTransferData { + let images: [DraggableImageEntity] +} + +/// 오늘의 코디 옷 정보 조회 +struct TodayCoordinateClothEntity { + let imageUrl: String + let brand: String + let name: String + let category: String + let parentCategory: String +} diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 38ad8203..84ce46ce 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -15,6 +15,8 @@ protocol HomeRepository { func postTodayTemp(request: PostTodayTemperatureAPIRequestDTO) async throws + func uploadCodiImage(jpgData: Data) async throws -> String + // MARK: - 코디가 없는 경우의 Home 관련 /// 날씨에 따른 카테고리별 옷 리스트 api 연결 @@ -28,28 +30,25 @@ protocol HomeRepository { /// 오늘의 코디 생성 func createTodayCoordinate(request: CreateTodayCoordinateRequestDTO) async throws -> TodayCoordinateEntity - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [TodayCoordinateClothEntity] - // 룩북 조회 func fetchLookBookList( lastLookBookId: Int64?, size: Int32, direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload ) async throws -> (content: [LookBookEntity], isLast: Bool) + + // MARK: - 코디가 있는 경우의 Home 관련 + /// 오늘의 코디 옷 정보 조회 + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO - /// 기존 api - - func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws - - // MARK: - 코디보드 + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] - func fetchInitialImages() -> [DraggableImageEntity] - func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws + /// 코디 수정 + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws - // MARK: - 코디가 있는 경우의 Home 관련 + /// 이전 일일 코디로 자동 생성 + func createAutoDailyCoordinate(request: CreateAutoDailyCoordinateAPIRequestDTO) async throws -> AutoDailyCoordinateEntity - func fetchCodiItems() -> [CodiItemEntity] func getToday() -> DateEntity // MARK: - 카테고리 수정 관련 diff --git a/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift index 6ef7bcf5..037c7496 100644 --- a/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift @@ -26,4 +26,11 @@ final class AddToLookBookUseCase { direction: direction ) } + + /// 이전 일일 코디로 자동 생성 + func createAutoDailyCoordinate( + request: CreateAutoDailyCoordinateAPIRequestDTO + ) async throws -> AutoDailyCoordinateEntity { + try await repository.createAutoDailyCoordinate(request: request) + } } diff --git a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift index c3701ccc..727379b2 100644 --- a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift @@ -14,30 +14,4 @@ final class CodiBoardUseCase { init(repository: HomeRepository) { self.repository = repository } - - func loadCodiBoardImages() -> [DraggableImageEntity] { - return repository.fetchInitialImages() - } - - func saveCodiItems(_ images: [DraggableImageEntity]) async throws { - let mockSnapshotUrl = "https://codive-storage.com/previews/\(UUID().uuidString).jpg" - - let payloads: [CodiCoordinatePayloadDTO] = images.enumerated().map { index, image in - return CodiCoordinatePayloadDTO( - clothId: Int64(image.id), - locationX: Double(image.position.x), - locationY: Double(image.position.y), - ratio: Double(image.scale), - degree: Double(image.rotation), - order: index - ) - } - - let request = CodiCoordinateRequestDTO( - coordinateImageUrl: mockSnapshotUrl, - Payload: payloads - ) - - try await repository.saveCodiCoordinate(request) - } } diff --git a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift index ca16573e..30e4b0e5 100644 --- a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift @@ -5,6 +5,8 @@ // Created by 한금준 on 12/25/25. // +import Foundation + final class TodayCodiUseCase { private let repository: HomeRepository @@ -21,17 +23,26 @@ final class TodayCodiUseCase { } /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [TodayCoordinateClothEntity] { - try await repository.fetchTodayCoordinateClothes() + func fetchTodayCoordinatePreview() async throws -> FetchTodayCoordinatePreviewResponseDTO { + return try await repository.fetchTodayCoordinatePreview() } -} - -extension TodayCodiUseCase { - func loadTodaysCodi() -> [CodiItemEntity] { - return repository.fetchCodiItems() + + func fetchTodayCoordinateDetails() async throws -> [FetchTodayCoordinateDetailsResponseDTO] { + return try await repository.fetchTodayCoordinateDetails() + } + + func execute(jpgData: Data) async throws -> String { + guard !jpgData.isEmpty else { + throw HomeAPIError.invalidResponse + } + + let uploadedURL = try await repository.uploadCodiImage(jpgData: jpgData) + + return uploadedURL } - func recordTodayCodi(_ codi: TodayDailyCodi) async throws { - try await repository.createTodayDailyCodi(codi) + /// 오늘의 코디 수정 + func patchUpdateCoordinates(coordinateId: Int64, request: EditCoordinateRequestDTO) async throws { + try await repository.patchUpdateCoordinates(coordinateId: coordinateId, request: request) } } diff --git a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift index 3aca0e40..5962d9d8 100644 --- a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift +++ b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift @@ -30,7 +30,6 @@ struct CategoryCounterView: View { Spacer() - // 감소 버튼: 이제 상의/하의 등도 isEmpty가 아니면(1이면) 0으로 줄일 수 있습니다. Button { if !isFixed && !isEmpty { count -= 1 diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index 79a37f4f..0552daa7 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -26,19 +26,15 @@ struct ClothCardView: View { .frame(height: 124) .frame(width: 124) .overlay { - if let url = URL(string: item.imageUrl), item.imageUrl.hasPrefix("http") { + if let url = URL(string: item.imageUrl), !item.imageUrl.isEmpty { AsyncImage(url: url) { phase in switch phase { case .empty: ProgressView() case .success(let image): - image - .resizable() - .scaledToFit() + image.resizable().scaledToFit() case .failure: - Image(systemName: "photo") - .resizable() - .scaledToFit() + Image(systemName: "exclamationmark.triangle") .foregroundColor(.gray) @unknown default: EmptyView() @@ -65,7 +61,7 @@ struct CodiClothCarouselView: View { let activeScale: CGFloat let inactiveScale: CGFloat let isEmptyState: Bool - + @ViewBuilder private func emptyStateCard(at index: Int, width: CGFloat) -> some View { let border = RoundedRectangle(cornerRadius: 15) @@ -172,6 +168,11 @@ struct CodiClothCarouselView: View { } ) } + .onChange(of: currentIndex) { newValue in + withAnimation(.spring()) { + proxy.scrollTo(newValue, anchor: .center) + } + } .scrollDisabled(isEmptyState) .onAppear { proxy.scrollTo(currentIndex, anchor: .center) @@ -191,23 +192,24 @@ struct CodiClothView: View { let title: String let items: [HomeClothEntity] let isEmptyState: Bool - var onIndexChanged: ((Int) -> Void)? - - // Carousel 설정 + let spacing: CGFloat = 12 let activeScale: CGFloat = 1.0 let inactiveScale: CGFloat = 0.85 + let selectedIndex: Int + var onIndexChanged: ((Int) -> Void)? @State private var currentIndex: Int - init(title: String, items: [HomeClothEntity], isEmptyState: Bool, onIndexChanged: ((Int) -> Void)? = nil) { + init(title: String, items: [HomeClothEntity], selectedIndex: Int = 0, isEmptyState: Bool, onIndexChanged: ((Int) -> Void)? = nil) { self.title = title self.items = items self.isEmptyState = isEmptyState + self.selectedIndex = selectedIndex self.onIndexChanged = onIndexChanged -// let initialIndex = isEmptyState ? 1 : max(0, items.count / 2) - let initialIndex = isEmptyState ? 1 : 0 + // 초기값 설정: 비어있으면 1(중앙), 아니면 전달받은 selectedIndex 사용 + let initialIndex = isEmptyState ? 1 : selectedIndex _currentIndex = State(initialValue: initialIndex) } @@ -225,6 +227,18 @@ struct CodiClothView: View { .onChange(of: currentIndex) { newValue in onIndexChanged?(newValue) } + // 중요: 부모가 준 selectedIndex가 바뀌면(수정 버튼 클릭 시) 내부 currentIndex도 동기화 + .onChange(of: selectedIndex) { newValue in + withAnimation(.spring()) { + self.currentIndex = newValue + } + } + .onAppear { + // 비어있는 상태가 아닐 때만 0번(또는 초기값)을 전달 + if !isEmptyState { + onIndexChanged?(currentIndex) + } + } // 카테고리 태그 Text(title) diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift index 943e0c90..091c915b 100644 --- a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -11,9 +11,8 @@ struct CompletePopUp: View { @Binding var isPresented: Bool var onRecordTapped: () -> Void var onCloseTapped: () -> Void - - // 수정: 단일 URL 대신 선택된 옷 리스트를 받음 var selectedClothes: [HomeClothEntity] + var singleImageUrl: String? var body: some View { ZStack { @@ -31,11 +30,54 @@ struct CompletePopUp: View { VStack(spacing: 6) { Text(TextLiteral.Home.popUpTitle).font(.codive_title1).padding(.top, 32) Text(TextLiteral.Home.popUpSubtitle).font(.codive_body2_regular).padding(.horizontal, 24) - - // 핵심 수정 부분: 이미지 합성 뷰 - CodiCompositeView(clothes: selectedClothes) - .frame(width: 260, height: 260) - .padding(.vertical, 16) + + Group { + if let imageUrl = singleImageUrl, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .onAppear { + #if DEBUG + print("⏳ [Popup] 이미지 로딩 시작: \(imageUrl)") + #endif + } + case .success(let image): + image.resizable() + .scaledToFill() + .onAppear { + #if DEBUG + print("✅ [Popup] 이미지 로드 성공") + #endif + } + case .failure(let error): + VStack { + Image(systemName: "exclamationmark.triangle") + Text("로드 실패") + } + .onAppear { + #if DEBUG + print("❌ [Popup] 이미지 로드 실패: \(error.localizedDescription)") + #endif + } + @unknown default: + EmptyView() + } + } + .frame(width: 260, height: 260) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.vertical, 16) + } else { + CodiCompositeView(clothes: selectedClothes) + .frame(width: 260, height: 260) + .padding(.vertical, 16) + .onAppear { + #if DEBUG + print("ℹ️ [Popup] 옷 리스트 합성 모드로 표시 중") + #endif + } + } + } HStack(spacing: 9) { CustomButton(text: TextLiteral.Home.close, widthType: .half, styleType: .border) { @@ -53,34 +95,40 @@ struct CompletePopUp: View { } } -// MARK: - 합성 레이아웃 뷰 struct CodiCompositeView: View { let clothes: [HomeClothEntity] - // 204 -> 260으로 변경 (아이템 3~4개 수직 배치 시 약 250pt 필요) + var loadedImages: [Int64: UIImage]? + let containerSize: CGFloat = 260 let itemSize: CGFloat = 100 var body: some View { ZStack { - // 배경 영역 (검정색 사각형이 이제 260 사이즈를 가집니다) - Rectangle() - .fill(Color.clear) + RoundedRectangle(cornerRadius: 12) + .fill(Color.Codive.grayscale7) .frame(width: containerSize, height: containerSize) - .cornerRadius(12) // 모서리를 살짝 깎으면 더 부드럽습니다 - // 아이템 배치 ForEach(0..: View { position: $item.position, scale: $item.scale, rotation: $item.rotation, - onActivate: { onActivate(item.id) } - ) { - // 순서 중요: KFImage 바로 뒤에 Kingfisher 전용 메서드를 배치합니다. - KFImage(URL(string: item.imageUrl)) - .placeholder { // 로딩 중 보여줄 뷰 - ProgressView() - .frame(width: 180, height: 180) - } - .onFailure { error in // 로드 실패 시 로직 - print("이미지 로드 실패: \(error.localizedDescription)") - } - .resizable() // 여기서부터는 일반 SwiftUI View로 변환됨 - .scaledToFit() - .frame(width: 180, height: 180) - } + onActivate: { onActivate(item.id) }, + content: { + KFImage(URL(string: item.imageUrl)) + .placeholder { + ProgressView() + .frame(width: 180, height: 180) + } + .onFailure { error in + print("이미지 로드 실패: \(error.localizedDescription)") + } + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + } + ) } } } diff --git a/Codive/Features/Home/Presentation/Component/SelectableClothItem.swift b/Codive/Features/Home/Presentation/Component/SelectableClothItem.swift index 56cd3a48..4192f903 100644 --- a/Codive/Features/Home/Presentation/Component/SelectableClothItem.swift +++ b/Codive/Features/Home/Presentation/Component/SelectableClothItem.swift @@ -13,19 +13,21 @@ struct SelectableClothItem: View { var body: some View { ZStack { - Image(entity.imageName) - .resizable() - .scaledToFill() - .frame(width: 68, height: 68) - .clipped() + AsyncImage(url: URL(string: entity.imageUrl)) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + Color.gray.opacity(0.2) + } + } + .frame(width: 68, height: 68) + .clipped() + .cornerRadius(8) } .frame(width: 72, height: 72) - .background(alignment: .center) { - Color.white - } - .overlay(alignment: .center) { + .overlay { RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? .black : .gray, lineWidth: 1) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) } .onTapGesture { isSelected.toggle() diff --git a/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift b/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift index 00929a7f..9fa6a599 100644 --- a/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift +++ b/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift @@ -21,8 +21,8 @@ struct ZoomRotateDragView: View { @GestureState private var gestureRotation: Angle = .zero // MARK: - 제약 조건 설정 - private let minScale: CGFloat = 0.4 // 최소 크기 (40%) - private let maxScale: CGFloat = 4.0 // 최대 크기 (400%) + private let minScale: CGFloat = 0.4 + private let maxScale: CGFloat = 4.0 init( id: Int64, @@ -42,7 +42,6 @@ struct ZoomRotateDragView: View { var body: some View { content - // 화면에 보이는 배율도 제한 범위 내에서만 움직이도록 시각적 보정 .scaleEffect(clampedScale(scale * gestureScale)) .rotationEffect(Angle(degrees: rotation) + gestureRotation) .offset(x: position.x + gestureOffset.width, y: position.y + gestureOffset.height) @@ -60,7 +59,6 @@ struct ZoomRotateDragView: View { MagnificationGesture() .updating($gestureScale) { v, s, _ in s = v } .onEnded { v in - // 제스처 종료 시 최종 scale 값을 제한 범위 내로 고정 let newScale = scale * v scale = min(max(newScale, minScale), maxScale) } @@ -73,7 +71,6 @@ struct ZoomRotateDragView: View { ) } - // 시각적으로 이미지가 너무 작아져서 사라지는 것을 방지하는 보조 함수 private func clampedScale(_ current: CGFloat) -> CGFloat { return min(max(current, minScale * 0.8), maxScale * 1.2) } diff --git a/Codive/Features/Home/Presentation/View/CodiBoardView.swift b/Codive/Features/Home/Presentation/View/CodiBoardView.swift index e9e77dfb..dd2c0967 100644 --- a/Codive/Features/Home/Presentation/View/CodiBoardView.swift +++ b/Codive/Features/Home/Presentation/View/CodiBoardView.swift @@ -27,6 +27,9 @@ struct CodiBoardView: View { let imageHalfSize: CGFloat = 40 contentView(boardSize: boardSize, imageHalfSize: imageHalfSize, totalWidth: geometry.size.width) + .onAppear { + viewModel.boardSize = boardSize + } } } .navigationBarHidden(true) @@ -53,9 +56,9 @@ private extension CodiBoardView { func contentView(boardSize: CGFloat, imageHalfSize: CGFloat, totalWidth: CGFloat) -> some View { VStack(spacing: 0) { descriptionText - + drawingBoard(size: boardSize, imageHalfSize: imageHalfSize) - + Spacer() } .frame( @@ -78,13 +81,16 @@ private extension CodiBoardView { /// 이미지들을 배치하고 드래그할 수 있는 보드 영역 func drawingBoard(size: CGFloat, imageHalfSize: CGFloat) -> some View { - return ZStack { + ZStack { boardBackground(size: size) - - // DraggableImageContainerView 사용 -// DraggableImageView(viewModel: viewModel) + + DraggableImageView(items: $viewModel.images) { id in + viewModel.selectImage(id: Int(id)) + viewModel.bringImageToFront(id: Int(id)) + } } .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 15)) .padding(.horizontal, 20) .padding(.bottom, 20) } diff --git a/Codive/Features/Home/Presentation/View/EditCategoryView.swift b/Codive/Features/Home/Presentation/View/EditCategoryView.swift index e9b04622..fa57d7b5 100644 --- a/Codive/Features/Home/Presentation/View/EditCategoryView.swift +++ b/Codive/Features/Home/Presentation/View/EditCategoryView.swift @@ -44,15 +44,13 @@ struct EditCategoryView: View { // MARK: - View Components private extension EditCategoryView { - - /// 상단 네비게이션 바 + var navigationBar: some View { CustomNavigationBar(title: TextLiteral.Home.editCategoryTitle) { viewModel.handleBackTap() } } - - /// 현재 카테고리 개수 표시 헤더 + var categoryHeader: some View { Text("\(TextLiteral.Home.currentCategoryCount) (\(viewModel.totalCount)/7)") .font(.codive_title2) @@ -61,8 +59,7 @@ private extension EditCategoryView { .padding(.horizontal, 20) .padding(.vertical, 24) } - - /// 카테고리 아이템 리스트 + var categoryList: some View { VStack(spacing: 24) { ForEach($viewModel.categories, id: \.id) { $category in @@ -78,7 +75,6 @@ private extension EditCategoryView { .padding(.bottom, 20) } - /// 하단 초기화 및 적용 버튼 var bottomActionButtons: some View { HStack(spacing: 9) { CustomButton( @@ -102,7 +98,6 @@ private extension EditCategoryView { .background(Color.white) } - /// 수정 취소 시 나타나는 알럿 버튼들 @ViewBuilder var alertButtons: some View { Button(TextLiteral.Common.cancel, role: .cancel) { diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index 2de9c735..f7d25444 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -19,7 +19,22 @@ struct HomeHasCodiView: View { VStack(alignment: .leading, spacing: 16) { header - codiDisplayArea + GeometryReader { canvasProxy in + let canvasSize = canvasProxy.size + + ZStack(alignment: .bottomLeading) { + boardBackground(size: canvasSize) + + if let selectedID = viewModel.selectedItemID, + let selectedItem = viewModel.codiItems.first(where: { $0.coordinateClothId == Int64(selectedID) }) { + tagOverlay(for: selectedItem, in: canvasSize) + } + + tagToggleButton + } + } + .frame(width: max(width - 40, 0), height: max(width - 40, 0)) + .padding(.horizontal, 20) if viewModel.showClothSelector { clothSelector @@ -35,8 +50,6 @@ struct HomeHasCodiView: View { // MARK: - View Components private extension HomeHasCodiView { - - /// 상단 헤더 (오늘의 코디 제목 및 날짜) var header: some View { HStack { Text("\(TextLiteral.Home.todayCodiTitle)(\(viewModel.todayString))") @@ -45,65 +58,11 @@ private extension HomeHasCodiView { Spacer() } } - - /// 코디 아이템 및 태그가 표시되는 메인 캔버스 영역 - var codiDisplayArea: some View { - GeometryReader { canvasProxy in - let canvasSize = canvasProxy.size - - ZStack(alignment: .bottomLeading) { - // 배경 레이어 - boardBackground(size: canvasSize) - - // 이미지 아이템 레이어 - ForEach(viewModel.codiItems) { item in - Image(item.imageName) - .resizable() - .scaledToFit() - .frame(width: item.width, height: item.height) - .position(x: item.x, y: item.y) - } - - // 선택된 아이템의 태그 레이어 - if let selectedID = viewModel.selectedItemID, - let selectedItem = viewModel.codiItems.first(where: { $0.id == selectedID }) { - tagOverlay(for: selectedItem, in: canvasSize) - } - - // 태그 셀렉터 토글 버튼 - tagToggleButton - } - } - .frame(width: max(width - 40, 0), height: max(width - 40, 0)) - .padding(.horizontal, 20) - } - - /// 하단 의류 선택 스크롤 뷰 - var clothSelector: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(viewModel.codiItems) { item in - SelectableClothItem( - entity: item, - isSelected: Binding( - get: { viewModel.selectedItemID ?? 0 == item.id }, - set: { isSelected in - viewModel.selectItem(isSelected ? Int(item.id) : nil) - } - ) - ) - } - } - .padding(.horizontal, 20) - } - } /// 하단 배너 var bottomBanner: some View { - CustomBanner(text: TextLiteral.Home.bannerTitle) { -// viewModel.rememberCodi() - } - .padding() + CustomBanner(text: TextLiteral.Home.bannerTitle) {} + .padding() } /// 우측 상단 오버플로우 메뉴 @@ -113,7 +72,7 @@ private extension HomeHasCodiView { menuActions: [ { viewModel.selectEditCodi() }, { viewModel.addLookbook() }, - { /*viewModel.sharedCodi()*/ } + { viewModel.sharedCodi() } ] ) .zIndex(9999) @@ -122,41 +81,32 @@ private extension HomeHasCodiView { // MARK: - Helper Methods & Subviews private extension HomeHasCodiView { - - /// 캔버스 배경 디자인 func boardBackground(size: CGSize) -> some View { RoundedRectangle(cornerRadius: 15) - .fill(Color.Codive.grayscale7) - .frame(width: size.width, height: size.height) + .fill(Color.gray.opacity(0.1)) .overlay { - RoundedRectangle(cornerRadius: 15) - .stroke(Color.gray.opacity(0.4), lineWidth: 1) + if let imageUrl = viewModel.todayCodiPreview?.imageUrl { + AsyncImage(url: URL(string: imageUrl)) { image in + image.resizable().scaledToFill() + } placeholder: { + ProgressView() + } + .clipShape(RoundedRectangle(cornerRadius: 15)) + } } } - /// 태그 표시 로직 분리 @ViewBuilder func tagOverlay(for item: CodiItemEntity, in canvasSize: CGSize) -> some View { - let isLeftSide = item.x < (canvasSize.width / 2) - let tagOffset: CGFloat = 120 - let tagWidth: CGFloat = 100 - let tagHeight: CGFloat = 40 - - ForEach(viewModel.selectedItemTags) { tag in - let baseX = item.x + (tag.locationX - 0.5) * item.width - let baseY = item.y + (tag.locationY - 0.5) * item.height - - let tagX = baseX + (isLeftSide ? tagOffset : -tagOffset) - - // 캔버스 이탈 방지 clamping - let clampedX = min(max(tagX, tagWidth / 2), canvasSize.width - tagWidth / 2) - let clampedY = min(max(baseY, tagHeight / 2), canvasSize.height - tagHeight / 2) - - CustomTagView(type: .basic(title: tag.title, content: tag.content)) - .position(x: clampedX, y: clampedY) - .transition(.opacity.combined(with: .scale)) - .zIndex(100) - } + CustomTagView(type: .basic( + title: item.brand, + content: item.name + )) + .position( + x: canvasSize.width * CGFloat(item.locationX), + y: canvasSize.height * CGFloat(item.locationY) + ) + .transition(.opacity.combined(with: .scale)) } /// 태그 표시 토글 버튼 @@ -168,4 +118,27 @@ private extension HomeHasCodiView { } .padding([.leading, .bottom], 16) } + + var clothSelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(viewModel.codiItems, id: \.coordinateClothId) { item in + SelectableClothItem( + entity: item, + isSelected: .init( + get: { viewModel.selectedItemID == Int(item.coordinateClothId) }, + set: { newValue in + if newValue { + viewModel.selectItem(Int(item.coordinateClothId)) + } else { + viewModel.selectItem(nil) + } + } + ) + ) + } + } + .padding(.horizontal, 20) + } + } } diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index 22407185..0c3a1134 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -12,7 +12,7 @@ struct HomeNoCodiView: View { // MARK: - Properties @ObservedObject var viewModel: HomeViewModel @State private var draggingItem: CategoryEntity? - + // MARK: - Body var body: some View { VStack(spacing: 0) { @@ -28,7 +28,9 @@ struct HomeNoCodiView: View { .onAppear { viewModel.onAppear() Task { - await viewModel.loadRecommendCategoryClothList() + if viewModel.clothItemsByCategory.isEmpty { + await viewModel.loadRecommendCategoryClothList(seasons: viewModel.currentSeasons) + } } } } @@ -65,16 +67,19 @@ private extension HomeNoCodiView { var codiClothList: some View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 16) { - ForEach(viewModel.activeCategories) { category in - let clothItems = viewModel.clothItemsByCategory[category.id] ?? [] + ForEach(viewModel.activeCategories, id: \.id) { category in + let items: [HomeClothEntity] = viewModel.clothItemsByCategory[category.id] ?? [] + let selectedIdx: Int = viewModel.selectedIndicesByCategory[category.id] ?? 0 CodiClothView( title: category.title, - items: clothItems, - isEmptyState: clothItems.isEmpty + items: items, + selectedIndex: selectedIdx, + isEmptyState: items.isEmpty ) { newIndex in viewModel.updateSelectedIndex(for: category.id, index: newIndex) } + .id(category.id) .background(Color.white) .cornerRadius(15) .onDrag { @@ -149,14 +154,14 @@ struct CategoryDropDelegate: DropDelegate { } } } - + func performDrop(info: DropInfo) -> Bool { withAnimation(.easeInOut) { draggingItem = nil } return true } - + func dropUpdated(info: DropInfo) -> DropProposal? { return DropProposal(operation: .move) } diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index 90e793f6..8e8dc4f4 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -63,12 +63,15 @@ struct HomeView: View { } .task { await viewModel.loadWeather(for: nil) -// await viewModel.loadActiveCategoriesWithAPI() + if !viewModel.isEditingExistingCodi { + viewModel.fetchTodayCodiData() + } } .onChange(of: navigationRouter.currentDestination) { newDestination in if newDestination == nil { - viewModel.loadActiveCategories() - // 홈으로 돌아올 때 스크롤 초기화 + if viewModel.todayCodiPreview == nil { + viewModel.loadActiveCategories() + } scrollViewID = UUID() } } diff --git a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift index f75af900..799bb455 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine @MainActor final class CodiBoardViewModel: ObservableObject { @@ -14,10 +15,13 @@ final class CodiBoardViewModel: ObservableObject { @Published var isConfirmed: Bool = false @Published var images: [DraggableImageEntity] = [] + private var cancellables = Set() @Published var currentlyDraggedID: Int? - @Published var selectedImageID: Int? // 추가된 속성 + @Published var selectedImageID: Int? + @Published var boardSize: CGFloat = 260 private let codiBoardUseCase: CodiBoardUseCase + private let todayCodiUseCase: TodayCodiUseCase private let navigationRouter: NavigationRouter private weak var homeViewModel: HomeViewModel? @@ -26,26 +30,40 @@ final class CodiBoardViewModel: ObservableObject { init( navigationRouter: NavigationRouter, codiBoardUseCase: CodiBoardUseCase, + todayCodiUseCase: TodayCodiUseCase, homeViewModel: HomeViewModel? = nil ) { self.navigationRouter = navigationRouter self.codiBoardUseCase = codiBoardUseCase + self.todayCodiUseCase = todayCodiUseCase self.homeViewModel = homeViewModel - loadInitialData() + setupCodiDataSubscription() } // MARK: - Private Methods /// 초기 코디판 이미지 데이터를 로드 - private func loadInitialData() { - self.images = codiBoardUseCase.loadCodiBoardImages() + private func setupCodiDataSubscription() { + HomeViewModel.codiTransferPublisher + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] transferredData in + guard let self = self else { return } + + self.images = transferredData.images + } + .store(in: &cancellables) } // MARK: - Navigation - - /// 이전 화면으로 이동 func handleBackTap() { + if let homeVM = homeViewModel { + if homeVM.todayCodiPreview != nil { + homeVM.hasCodi = true + } + homeVM.isEditingExistingCodi = false + } navigationRouter.navigateBack() } @@ -53,31 +71,77 @@ final class CodiBoardViewModel: ObservableObject { /// 구성된 코디를 서버에 저장하고 홈 화면의 완료 팝업을 띄움 func handleConfirmCodi() { -// Task { -// do { -// // 1. 서버에 데이터 전송 -// try await codiBoardUseCase.saveCodiItems(images) -// -// // 2. 저장 성공 후 UI 처리 (MainActor에서 실행됨) -// let imageURL = images.first?.imageURL -// navigationRouter.navigateBack() -// -// // 홈 화면으로 돌아가는 애니메이션 시간을 고려하여 지연 실행 -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in -// self?.homeViewModel?.showCompletionPopup(imageURL: imageURL) -// } -// -// self.isConfirmed = true -// } catch { -// handleError(error) -// } -// } + Task { + let actualSize = boardSize + let centerOffset = actualSize / 2 + + let captureView = ZStack { + RoundedRectangle(cornerRadius: 15) + .fill(Color.Codive.grayscale7) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(Color.Codive.grayscale5, lineWidth: 1) + ) + + DraggableImageView(items: .constant(images)) { _ in } + } + .frame(width: actualSize, height: actualSize) + .clipShape(RoundedRectangle(cornerRadius: 15)) + + let renderer = ImageRenderer(content: captureView) + renderer.scale = UIScreen.main.scale + + guard var uiImage = renderer.uiImage else { return } + + let targetSize = CGSize(width: 260, height: 260) + uiImage = resizeImage(image: uiImage, targetSize: targetSize) + + guard let jpgData = uiImage.jpegData(compressionQuality: 0.8) else { return } + + do { + let uploadedURL = try await todayCodiUseCase.execute(jpgData: jpgData) + + let finalPayloads = images.enumerated().map { index, entity in + let absoluteX = max(0, min(actualSize, entity.position.x + centerOffset)) + let absoluteY = max(0, min(actualSize, entity.position.y + centerOffset)) + + let positiveDegree = entity.rotation < 0 ? entity.rotation + 360 : entity.rotation + + return Payloads( + clothId: entity.id, + locationX: Double(absoluteX / actualSize), + locationY: Double(absoluteY / actualSize), + ratio: Double(entity.scale), + degree: positiveDegree, + order: Int32(index + 1) + ) + } + + await MainActor.run { + guard let homeVM = homeViewModel else { return } + homeVM.capturedImageURL = uploadedURL + homeVM.boardPayloads = finalPayloads + navigationRouter.navigateBack() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + homeVM.showCompletePopUp = true + } + } + } catch { + print("❌ 에러: \(error.localizedDescription)") + } + } + } + + private func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetSize)) + } } - /// 에러 발생 시 처리 로직 private func handleError(_ error: Error) { print("코디 저장 실패: \(error.localizedDescription)") - // TODO: 필요한 경우 사용자에게 보여줄 에러 알럿 로직 추가 } // MARK: - DraggableImageViewModelProtocol Implementation diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift new file mode 100644 index 00000000..aafeb93f --- /dev/null +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift @@ -0,0 +1,205 @@ +// +// HomeViewModel+.swift +// Codive +// +// Created by 한금준 on 2/6/26. +// + +import SwiftUI +import Photos + +// MARK: - LookBook Actions +extension HomeViewModel { + /// 오늘의 코디 수정 + func selectEditCodi() { + + // 2. 수정 모드 플래그 활성화 및 화면 전환 + self.isEditingExistingCodi = true + self.hasCodi = false + + Task { + // 옷 리스트가 없으면 로드 + if clothItemsByCategory.isEmpty { + await loadRecommendCategoryClothList(seasons: self.currentSeasons) + } + + var restoredIndices: [Int: Int] = [:] + + for item in codiItems { + if let category = activeCategories.first(where: { $0.title == item.parentCategory }) { + if let clothList = clothItemsByCategory[category.id], + let index = clothList.firstIndex(where: { $0.clothId == item.clothId }) { + restoredIndices[category.id] = index + } + } + } + + await MainActor.run { + self.selectedIndicesByCategory = restoredIndices + } + } + } + + /// 수정 취소 또는 뒤로가기 시 상태를 복구하고 싶을 때 사용 (선택 사항) + func cancelEditCodi() { + if todayCodiPreview != nil { + self.hasCodi = true + } + } + + func sharedCodi() { + // 1. 저장할 이미지 URL 확인 + guard let imageUrlString = todayCodiPreview?.imageUrl, + let url = URL(string: imageUrlString) else { + print("⚠️ [Save] 저장할 이미지 URL이 없습니다.") + return + } + + Task { + do { + // 2. 이미지 데이터 다운로드 + let (data, _) = try await URLSession.shared.data(from: url) + guard let image = UIImage(data: data) else { + print("⚠️ [Save] 이미지 변환 실패") + return + } + + // 3. 사진첩 저장 실행 + saveToPhotoLibrary(image: image) + } catch { + print("❌ [Save] 다운로드 실패: \(error.localizedDescription)") + } + } + } + + private func saveToPhotoLibrary(image: UIImage) { + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + if status == .authorized || status == .limited { + PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: image) + } completionHandler: { success, error in + if success { + print("✅ 사진첩 저장 성공") + } else if let error = error { + print("❌ 저장 실패: \(error.localizedDescription)") + } + } + } else { + print("⚠️ 사진첩 접근 권한이 거부되었습니다.") + } + } + } + /// 내 룩북 리스트를 불러와 바텀시트를 표시 + func addLookbook() { + Task { + do { + let (content, _) = try await addToLookBookUseCase.fetchLookBookList( + lastLookBookId: nil, + size: 20, + direction: .DESC + ) + + self.lookBookList = content.map { entity in + LookBookBottomSheetEntity( + lookbookId: entity.lookBookId, + imageUrl: entity.imageUrl, + title: entity.lookbookName, + count: entity.count + ) + } + + self.showLookBookSheet = true + } catch { + print("❌ 룩북 리스트 로드 실패: \(error.localizedDescription)") + } + } + } + + func selectLookBook(_ entity: LookBookBottomSheetEntity) { + guard let dailyCodiId = todayCodiPreview?.coordinateId else { + print("⚠️ [Lookbook] 추가할 오늘의 코디 정보가 없습니다.") + return + } + + Task { + do { + let request = CreateAutoDailyCoordinateAPIRequestDTO( + name: "\(todayString) 코디", + memo: "", + dailyCoordinateId: dailyCodiId, + lookBookId: entity.lookbookId + ) + + let result = try await addToLookBookUseCase.createAutoDailyCoordinate(request: request) + + await MainActor.run { + self.showLookBookSheet = false + } + } catch { + print("❌ [Lookbook] 룩북 추가 실패: \(error.localizedDescription)") + } + } + } +} + +extension HomeViewModel { + /// 오늘의 코디 조회 + func fetchTodayCodiData() { + guard !isEditingExistingCodi else { return } + Task { + do { + // 1. 배경 이미지(Preview)와 상세 정보(Details)를 병렬로 호출 + async let previewReq = todayCodiUseCase.fetchTodayCoordinatePreview() + async let detailsReq = todayCodiUseCase.fetchTodayCoordinateDetails() + + let (preview, details) = try await (previewReq, detailsReq) + + self.todayCodiPreview = preview + + // 2. 서버 응답 DTO를 UI에서 사용하는 CodiItemEntity로 매핑 + self.codiItems = details.map { detail in + CodiItemEntity( + coordinateClothId: detail.coordinateClothId, + locationX: detail.locationX, + locationY: detail.locationY, + ratio: detail.ratio, + degree: detail.degree, + order: detail.order, + clothId: detail.clothId, + imageUrl: detail.imageUrl, + brand: detail.brand, + name: detail.name, + category: detail.category, + parentCategory: detail.parentCategory + ) + } + + self.hasCodi = true + } catch { + self.hasCodi = false + print("❌ 데이터 로드 실패: \(error)") + } + } + } + + func toggleClothSelector() { + withAnimation(.spring()) { + showClothSelector.toggle() + if !showClothSelector { selectedItemID = nil } + } + } + + func selectItem(_ id: Int?) { + withAnimation(.spring()) { + selectedItemID = id + } + } + + /// 드래그를 통해 태그의 상대 위치를 업데이트 + func updateTagPosition(tagId: UUID, x: CGFloat, y: CGFloat, imageSize: CGSize) { + if let index = selectedItemTags.firstIndex(where: { $0.id == tagId }) { + selectedItemTags[index].locationX = x / imageSize.width + selectedItemTags[index].locationY = y / imageSize.height + } + } +} diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 09e09c9f..971e00c7 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -12,9 +12,11 @@ import CoreLocation @MainActor final class HomeViewModel: ObservableObject { + static let codiTransferPublisher = CurrentValueSubject(nil) + // MARK: - Properties (UI State) - @Published var hasCodi: Bool = true + @Published var hasCodi: Bool = false @Published var showClothSelector: Bool = false @Published var selectedItemID: Int? @Published var selectedIndex: Int? = 0 @@ -22,6 +24,9 @@ final class HomeViewModel: ObservableObject { @Published var showCompletePopUp: Bool = false @Published var showLookBookSheet: Bool = false @Published var completedCodiImageURL: String? + @Published var capturedImageURL: String? + + @Published var isEditingExistingCodi: Bool = false // MARK: - Properties (Data) @@ -36,19 +41,19 @@ final class HomeViewModel: ObservableObject { @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] @Published var selectedIndicesByCategory: [Int: Int] = [:] @Published var selectedCodiClothes: [HomeClothEntity] = [] + @Published var todayCodiPreview: FetchTodayCoordinatePreviewResponseDTO? + + @Published var boardPayloads: [Payloads] = [] // MARK: - Dependencies let navigationRouter: NavigationRouter private let fetchWeatherUseCase: FetchWeatherUseCase - private let todayCodiUseCase: TodayCodiUseCase + internal let todayCodiUseCase: TodayCodiUseCase private let dateUseCase: DateUseCase private let categoryUseCase: CategoryUseCase - private let addToLookBookUseCase: AddToLookBookUseCase + internal let addToLookBookUseCase: AddToLookBookUseCase - // MARK: - Computed Properties - - /// 현재 활성화된 모든 카테고리에 아이템이 하나도 없는지 확인 var isAllCategoriesEmpty: Bool { let totalItemCount = activeCategories.reduce(0) { sum, category in sum + (clothItemsByCategory[category.id]?.count ?? 0) @@ -56,6 +61,11 @@ final class HomeViewModel: ObservableObject { return totalItemCount == 0 } + var currentSeasons: Set { + let temp = Int(weatherData?.currentTemp ?? 20) + if temp >= 18 { return [.summer] } else if temp >= 7 { return [.spring, .fall] } else { return [.winter] } + } + // MARK: - Initializer init( @@ -76,49 +86,38 @@ final class HomeViewModel: ObservableObject { loadInitialData() } - // MARK: - Life Cycle - func onAppear() { loadActiveCategories() + fetchTodayCodiData() } } -// MARK: - Data Loading Methods extension HomeViewModel { - - /// 앱 실행 시 필요한 초기 데이터를 로드 func loadInitialData() { - loadDummyCodi() loadToday() loadActiveCategories() } - /// 현재 날짜 정보를 가져옴 func loadToday() { let entity = dateUseCase.getToday() self.todayString = entity.formattedDate } - /// 로컬에 저장된 활성화 카테고리 설정을 동기적으로 불러옴 func loadActiveCategories() { let allCategories = categoryUseCase.loadCategories() self.activeCategories = allCategories.filter { $0.itemCount > 0 } } - /// 오늘 이미 생성된 코디(더미) 데이터를 불러옴 - func loadDummyCodi() { - codiItems = todayCodiUseCase.loadTodaysCodi() - } -} - -// MARK: - API & Async Methods -extension HomeViewModel { func loadWeather(for location: CLLocation?) async { do { let weather = try await fetchWeatherUseCase.execute(for: location) self.weatherData = weather - + let temperature = weather.currentTemp + + let targetSeasons = determineSeasons(from: temperature) + await loadRecommendCategoryClothList(seasons: targetSeasons) + let request = PostTodayTemperatureAPIRequestDTO( temperature: Double(temperature) ) @@ -128,24 +127,37 @@ extension HomeViewModel { print("Weather load or post failed:", error) } } + + internal func determineSeasons(from temperature: Int) -> Set { + if temperature >= 30 { + return [.summer] + } else if temperature >= 10 { + return [.spring, .fall] + } else { + return [.winter] + } + } +} - /// 카테고리별 계절에 맞는 옷 조회 - func loadRecommendCategoryClothList() async { +extension HomeViewModel { + func loadRecommendCategoryClothList(seasons: Set) async { + self.activeCategories = [] + self.clothItemsByCategory = [:] + let allCategories = categoryUseCase.loadCategories() let filteredCategories = allCategories.filter { $0.itemCount > 0 } + self.activeCategories = filteredCategories - + var resultMap: [Int: [HomeClothEntity]] = [:] - + for category in filteredCategories { - print("📦 category title:", category.title) - print("📦 category.id:", category.id) do { let result = try await categoryUseCase.loadClothItems( lastClothId: nil, size: 10, categoryId: Int64(category.id), - season: [.spring] + season: seasons // 전달받은 seasons 사용 ) resultMap[category.id] = result.content } catch { @@ -153,85 +165,68 @@ extension HomeViewModel { resultMap[category.id] = [] } } - + self.clothItemsByCategory = resultMap } -} - -// MARK: - UI Logic & Actions -extension HomeViewModel { - /// 코디 이미지 내의 태그 표시 셀렉터를 토글 - func toggleClothSelector() { - withAnimation(.spring()) { - showClothSelector.toggle() - if !showClothSelector { - selectedItemID = nil - selectedItemTags = [] - } - } - } - - /// 코디판 이미지 중 특정 아이템을 선택하여 태그를 표시 - func selectItem(_ id: Int?) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - selectedItemID = id - guard let id = id, let item = codiItems.first(where: { $0.id == id }) else { - self.selectedItemTags = [] - return - } - - self.selectedItemTags = [ - ClothTagEntity( - title: item.brandName, - content: item.clothName, - locationX: 0.5, - locationY: 0.5 - ) - ] - } - } - - /// 드래그를 통해 태그의 상대 위치를 업데이트 - func updateTagPosition(tagId: UUID, x: CGFloat, y: CGFloat, imageSize: CGSize) { - if let index = selectedItemTags.firstIndex(where: { $0.id == tagId }) { - selectedItemTags[index].locationX = x / imageSize.width - selectedItemTags[index].locationY = y / imageSize.height - } + func moveCategory(from source: IndexSet, to destination: Int) { + activeCategories.move(fromOffsets: source, toOffset: destination) + + // 순서 변경을 로컬에 저장하려면 categoryUseCase를 통해 저장 + // categoryUseCase.saveCategoryOrder(activeCategories) } - /// 카테고리별로 선택된 의류의 인덱스를 업데이트 func updateSelectedIndex(for categoryId: Int, index: Int) { selectedIndicesByCategory[categoryId] = index } } -// MARK: - Navigation extension HomeViewModel { - - /// 코디보드 화면으로 이동 - func handleCodiBoardTap() { - navigationRouter.navigate(to: .codiBoard) - } - - /// 카테고리 편집 화면으로 이동 func handleEditCategory() { navigationRouter.navigate(to: .editCategory) } - /// 룩북으로 이동 - func selectEditCodi() { - navigationRouter.navigate(to: .lookbook) + func handleCodiBoardTap() { + let containerSize: CGFloat = 260 + let centerOffset = containerSize / 2 + + let transferImages = activeCategories.enumerated().compactMap { index, category -> DraggableImageEntity? in + guard let clothList = clothItemsByCategory[category.id], + let selectedIndex = selectedIndicesByCategory[category.id] else { + return nil + } + + let cloth = clothList.indices.contains(selectedIndex) ? clothList[selectedIndex] : clothList.first + guard let selectedCloth = cloth else { return nil } + + let rawPos = CodiLayoutCalculator.position( + index: index, + totalCount: activeCategories.count, + containerSize: containerSize + ) + + let relativePos = CGPoint( + x: rawPos.x - centerOffset, + y: rawPos.y - centerOffset + ) + + return DraggableImageEntity( + id: selectedCloth.clothId, + name: selectedCloth.imageUrl, + position: relativePos, + scale: 0.7, + rotation: 0 + ) + } + + let data = TodayCodiTransferData(images: transferImages) + + Self.codiTransferPublisher.send(data) + navigationRouter.navigate(to: .codiBoard) } -} - -// MARK: - Popup & Decision Actions -extension HomeViewModel { - /// 현재 스크롤된 의류 조합을 수집하고 완료 팝업을 띄움 func handleConfirmCodiTap() { let items = activeCategories - .sorted { $0.id < $1.id } .compactMap { category -> HomeClothEntity? in guard let clothList = clothItemsByCategory[category.id] else { return nil } let index = selectedIndicesByCategory[category.id] ?? 0 @@ -239,139 +234,173 @@ extension HomeViewModel { } self.selectedCodiClothes = items - self.showCompletePopUp = true + + Task { + var loadedImages: [Int64: UIImage] = [:] + await withTaskGroup(of: (Int64, UIImage?).self) { group in + for cloth in items { + group.addTask { + let image = await self.downloadUIImage(from: cloth.imageUrl) + return (cloth.clothId, image) + } + } + for await (id, image) in group { + if let img = image { loadedImages[id] = img } + } + } + + let captureView = CodiCompositeView(clothes: items, loadedImages: loadedImages) + .frame(width: 260, height: 260) + + let renderer = ImageRenderer(content: captureView) + renderer.scale = UIScreen.main.scale + + guard let uiImage = renderer.uiImage else { + return + } + + guard let jpgData = uiImage.jpegData(compressionQuality: 0.8) else { return } + + do { + let uploadedURL = try await todayCodiUseCase.execute(jpgData: jpgData) + + await MainActor.run { + self.capturedImageURL = uploadedURL + self.showCompletePopUp = true + print("🚀 [Home Success] 최종 이미지 URL: \(uploadedURL)") + } + } catch { + print("❌ [Home Capture] 서버 에러: \(error.localizedDescription)") + } + } } - +} + +extension HomeViewModel { /// 코디 확정 후 완료 팝업을 표시 func showCompletionPopup(imageURL: String?) { completedCodiImageURL = imageURL showCompletePopUp = true } - /// 팝업에서 '기록하기' 버튼을 눌러 오늘 완성한 코디를 서버에 전송 + func showCompletionFromBoard(payloads: [Payloads], imageURL: String) { + self.boardPayloads = payloads + self.capturedImageURL = imageURL + self.showCompletePopUp = true + } + func handlePopupRecord() { Task { do { - // 1️⃣ CodiCompositeView 캡처 - let image = captureCompletedCodiImage() - - // 2️⃣ UIImage → Base64 String - guard let coordinateImageUrl = image.toBase64String() else { - throw NSError(domain: "Base64EncodingFail", code: 0) + guard let imageURL = self.capturedImageURL else { return } + + let rawPayloads = boardPayloads.isEmpty ? createPayloadsFromCurrentList() : boardPayloads + + let finalPayloads = rawPayloads.map { p in + Payloads( + clothId: p.clothId, + locationX: p.locationX, + locationY: p.locationY, + ratio: p.ratio, + degree: p.degree, + order: Int32(p.order) + ) } - - self.completedCodiImageURL = coordinateImageUrl - - // 3️⃣ 좌표 payload 생성 - let containerSize: CGFloat = 260 - let payloads = selectedCodiClothes.enumerated().map { index, cloth in - let position = CodiLayoutCalculator.position( - index: index, - totalCount: selectedCodiClothes.count, - containerSize: containerSize + + if isEditingExistingCodi, let coordinateId = todayCodiPreview?.coordinateId { + let editRequest = EditCoordinateRequestDTO( + coordinateImageUrl: imageURL, + name: "\(todayString) 코디", + memo: nil, + payloads: finalPayloads ) - - return Payloads( - clothId: cloth.clothId, - locationX: position.x, - locationY: position.y, - ratio: 1.0, - degree: 0, - order: Int32(index) + + try await todayCodiUseCase.patchUpdateCoordinates( + coordinateId: coordinateId, + request: editRequest ) - } - - // 4️⃣ 오늘의 코디 생성 (String 그대로 전달) - let request = CreateTodayCoordinateRequestDTO( - coordinateImageUrl: coordinateImageUrl, - payloads: payloads - ) - - let result = try await todayCodiUseCase.createTodayCoordinate(request: request) - print("✅ 오늘 코디 생성 완료:", result.coordinateId) - - showCompletePopUp = false - hasCodi = true - } catch { - print("❌ 코디 기록 실패:", error) - } - } - } - - /// 팝업을 닫기 - func handlePopupClose() { - showCompletePopUp = false - completedCodiImageURL = nil - } -} - -// MARK: - LookBook Actions -extension HomeViewModel { - /// 내 룩북 리스트를 불러와 바텀시트를 표시 - func addLookbook() { - Task { - do { - let (content, _) = try await addToLookBookUseCase.fetchLookBookList( - lastLookBookId: nil, - size: 20, - direction: .DESC - ) - - self.lookBookList = content.map { entity in - LookBookBottomSheetEntity( - lookbookId: entity.lookBookId, - imageUrl: entity.imageUrl, - title: entity.lookbookName, - count: entity.count + } else { + let createRequest = CreateTodayCoordinateRequestDTO( + coordinateImageUrl: imageURL, + payloads: finalPayloads ) + let result = try await todayCodiUseCase.createTodayCoordinate(request: createRequest) + print("✅ 오늘의 코디 신규 생성 성공 (ID: \(result.coordinateId))") } - // 3. 데이터 로딩 후 시트 표시 - self.showLookBookSheet = true + await MainActor.run { + self.completeProcess() + } } catch { - print("❌ 룩북 리스트 로드 실패: \(error.localizedDescription)") + print("- 에러 타입: \(error)") } } } - /// 바텀시트에서 특정 룩북을 선택 - func selectLookBook(_ entity: LookBookBottomSheetEntity) { - showLookBookSheet = false + private func completeProcess() { + self.isEditingExistingCodi = false + self.showCompletePopUp = false + self.hasCodi = true + self.boardPayloads = [] + self.capturedImageURL = nil + self.fetchTodayCodiData() } -} - -extension HomeViewModel { - /// 카테고리 순서 변경 - func moveCategory(from source: IndexSet, to destination: Int) { - activeCategories.move(fromOffsets: source, toOffset: destination) + private func createPayloadsFromCurrentList() -> [Payloads] { + let containerSize: CGFloat = 260 - // 순서 변경을 로컬에 저장하려면 categoryUseCase를 통해 저장 - // categoryUseCase.saveCategoryOrder(activeCategories) + return activeCategories.enumerated().compactMap { index, category -> Payloads? in + guard let clothList = clothItemsByCategory[category.id] else { return nil } + let selectedIndex = selectedIndicesByCategory[category.id] ?? 0 + let cloth = clothList.indices.contains(selectedIndex) ? clothList[selectedIndex] : clothList.first + + guard let selectedCloth = cloth else { return nil } + + let position = CodiLayoutCalculator.position( + index: index, + totalCount: activeCategories.count, + containerSize: containerSize + ) + + return Payloads( + clothId: selectedCloth.clothId, + locationX: Double(position.x / containerSize), + locationY: Double(position.y / containerSize), + ratio: 1.0, + degree: 0, + order: Int32(index + 1) + ) + } + } + + func handlePopupClose() { + showCompletePopUp = false + completedCodiImageURL = nil } - /// 이미지 캡처 private func captureCompletedCodiImage() -> UIImage { let view = CodiCompositeView(clothes: selectedCodiClothes) .frame(width: 260, height: 260) - + let controller = UIHostingController(rootView: view) let uiView = controller.view! uiView.bounds = CGRect(origin: .zero, size: CGSize(width: 260, height: 260)) uiView.backgroundColor = .clear - + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 260, height: 260)) return renderer.image { _ in uiView.drawHierarchy(in: uiView.bounds, afterScreenUpdates: true) } } -} - -extension UIImage { - func toBase64String() -> String? { - guard let data = self.jpegData(compressionQuality: 0.9) else { + + private func downloadUIImage(from urlString: String) async -> UIImage? { + guard let url = URL(string: urlString) else { return nil } + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { + print("❌ 이미지 다운로드 실패 (\(urlString)): \(error)") return nil } - return data.base64EncodedString() } } diff --git a/Codive/Features/LookBook/Data/DataSources/LookBookDataSource.swift b/Codive/Features/LookBook/Data/DataSources/LookBookDataSource.swift index 3bc53fdb..a423e474 100644 --- a/Codive/Features/LookBook/Data/DataSources/LookBookDataSource.swift +++ b/Codive/Features/LookBook/Data/DataSources/LookBookDataSource.swift @@ -39,9 +39,6 @@ protocol LookBookDataSourceProtocol { coordinateId: Int64 ) async throws -> [CoordinateDetailResponseDTO] - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] - /// 옷 리스트 조회 func fetchClothItems(category: String?) async throws -> [ProductItem] @@ -133,11 +130,6 @@ final class LookBookDataSource: LookBookDataSourceProtocol { return try await apiService.fetchCoordinateDetail(coordinateId: coordinateId) } - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] { - return try await apiService.fetchTodayCoordinateClothes() - } - /// 옷 리스트 조회 func fetchClothItems(category: String?) async throws -> [ProductItem] { // 전체 옷 목록 조회 (페이지네이션 없이 전체) diff --git a/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift b/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift index 7e3f77db..c55593fc 100644 --- a/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift +++ b/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift @@ -90,12 +90,6 @@ final class LookBookRepositoryImpl: LookBookRepository { return dtoList.map { $0.toEntity() } } - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [TodayCoordinateClothEntity] { - let dtoList = try await datasource.fetchTodayCoordinateClothes() - return dtoList.map { $0.toEntity() } - } - /// 옷 리스트 조회 func fetchClothItems(category: String?) async throws -> [ProductItem] { return try await datasource.fetchClothItems(category: category) diff --git a/Codive/Features/LookBook/Data/Services/LookBookAPIService.swift b/Codive/Features/LookBook/Data/Services/LookBookAPIService.swift index cb822d82..9a9ba338 100644 --- a/Codive/Features/LookBook/Data/Services/LookBookAPIService.swift +++ b/Codive/Features/LookBook/Data/Services/LookBookAPIService.swift @@ -101,11 +101,7 @@ extension LookBookAPIService { direction: Operations.Coordinate_getDailyCoordinates.Input.Query.directionPayload ) async throws -> PastDailyCoordinateResponseDTO { let input = Operations.Coordinate_getDailyCoordinates.Input( - query: .init( - lastCoordinateId: lastCoordinateId, - size: size, - direction: direction - ) + query: .init(lastCoordinateId: lastCoordinateId, size: size, direction: direction) ) let response = try await client.Coordinate_getDailyCoordinates(input) @@ -114,18 +110,25 @@ extension LookBookAPIService { case .ok(let okResponse): let data = try await Data(collecting: okResponse.body.any, upTo: .max) - let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseDailyCoordinateListResponse.self, from: data) - - let content: [PastDailyCoordinateListResponseItem] = - decoded.result?.content?.map { item -> PastDailyCoordinateListResponseItem in - return PastDailyCoordinateListResponseItem( - coordinateId: item.coordinateId ?? 0, - imageUrl: item.imageUrl ?? "", - date: formatDate(item.date) + do { + // 헬퍼 메서드를 사용하여 디코딩 + let decoded = try makeCustomDecoder().decode( + Components.Schemas.BaseResponseSliceResponseDailyCoordinateListResponse.self, + from: data ) - } ?? [] - - return PastDailyCoordinateResponseDTO(content: content, isLast: decoded.result?.isLast ?? true) + + let content: [PastDailyCoordinateListResponseItem] = decoded.result?.content?.map { item in + return PastDailyCoordinateListResponseItem( + coordinateId: item.coordinateId ?? 0, + imageUrl: item.imageUrl ?? "", + date: dateToSimpleString(item.date) // 헬퍼 메서드 사용 + ) + } ?? [] + + return PastDailyCoordinateResponseDTO(content: content, isLast: decoded.result?.isLast ?? true) + } catch { + throw LookBookAPIError.invalidResponse + } case .undocumented(statusCode: let code, _): throw LookBookAPIError.serverError(statusCode: code, message: "과거 일일 코디 조회 실패") @@ -204,34 +207,6 @@ extension LookBookAPIService { } } - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] { - let input = Operations.Coordinate_getTodayDailyCoordinateClothes.Input() - - let response = try await client.Coordinate_getTodayDailyCoordinateClothes(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.BaseResponseListDailyCoordinateClothResponse.self, from: data) - - let items = decoded.result ?? [] - - return items.map { item in - GetTodayCoordinateClothResponseDTO( - imageUrl: item.imageUrl ?? "", - brand: item.brand ?? "", - name: item.name ?? "", - category: item.category ?? "", - parentCategory: item.parentCategory ?? "" - ) - } - - case .undocumented(statusCode: let code, _): - throw LookBookAPIError.serverError(statusCode: code, message: "오늘의 코디 옷 정보 조회 실패") - } - } - func fetchClothes(lastClothId: Int64?, size: Int32, categoryId: Int64?, seasons: [Season]) async throws -> ClothListResult { let seasonsParam = seasons.isEmpty ? nil : seasons.map { mapSeasonToQueryParam($0) } @@ -415,7 +390,7 @@ extension LookBookAPIService { name: request.name, memo: request.memo, payloads: request.payloads?.map { - Components.Schemas.CoordinateUpdateRequestPayload ( + Components.Schemas.CoordinateUpdateRequestPayload( clothId: $0.clothId, locationX: $0.locationX, locationY: $0.locationY, @@ -517,6 +492,41 @@ extension LookBookAPIService { components.query = nil return components.string ?? presignedUrl } + + /// 다양한 날짜 형식을 처리할 수 있는 디코더를 생성합니다. + private func makeCustomDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + let simpleFormatter = DateFormatter() + simpleFormatter.dateFormat = "yyyy-MM-dd" + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + if let date = simpleFormatter.date(from: dateString) { return date } + if let date = isoFormatter.date(from: dateString) { return date } + + isoFormatter.formatOptions = [.withInternetDateTime] + if let date = isoFormatter.date(from: dateString) { return date } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported date format: \(dateString)" + ) + } + return decoder + } + + /// Date 객체를 다시 "yyyy-MM-dd" 문자열로 변환합니다. + private func dateToSimpleString(_ date: Date?) -> String { + guard let date = date else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } } // MARK: - ClothAPIError diff --git a/Codive/Features/LookBook/Data/Services/LookBookAPIServiceProtocol.swift b/Codive/Features/LookBook/Data/Services/LookBookAPIServiceProtocol.swift index e4a2b97a..227b4613 100644 --- a/Codive/Features/LookBook/Data/Services/LookBookAPIServiceProtocol.swift +++ b/Codive/Features/LookBook/Data/Services/LookBookAPIServiceProtocol.swift @@ -41,9 +41,6 @@ protocol LookBookAPIServiceProtocol { coordinateId: Int64 ) async throws -> [CoordinateDetailResponseDTO] - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [GetTodayCoordinateClothResponseDTO] - /// 옷 리스트 조회 func fetchClothes(lastClothId: Int64?, size: Int32, categoryId: Int64?, seasons: [Season]) async throws -> ClothListResult diff --git a/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift b/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift index 73b1a4a9..c92a0553 100644 --- a/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift +++ b/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift @@ -60,15 +60,6 @@ struct CoordinateDetailEntity { let parentCategory: String } -/// 오늘의 코디 옷 정보 조회 -struct TodayCoordinateClothEntity { - let imageUrl: String - let brand: String - let name: String - let category: String - let parentCategory: String -} - /// 코디 수동 생성 struct ManualCoordinateEntity { let coordinateId: Int64 diff --git a/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift b/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift index 22336670..afeba038 100644 --- a/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift +++ b/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift @@ -39,9 +39,6 @@ protocol LookBookRepository { coordinateId: Int64 ) async throws -> [CoordinateDetailEntity] - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [TodayCoordinateClothEntity] - /// 옷 리스트 조회 func fetchClothItems(category: String?) async throws -> [ProductItem] diff --git a/Codive/Features/LookBook/Domain/UseCases/CodiUseCase.swift b/Codive/Features/LookBook/Domain/UseCases/CodiUseCase.swift index 7d01ef88..8517448c 100644 --- a/Codive/Features/LookBook/Domain/UseCases/CodiUseCase.swift +++ b/Codive/Features/LookBook/Domain/UseCases/CodiUseCase.swift @@ -31,12 +31,7 @@ final class CodiUseCase { ) async throws -> [CoordinateDetailEntity] { try await repository.fetchCoordinateDetail(coordinateId: coordinateId) } - - /// 오늘의 코디 옷 정보 조회 - func fetchTodayCoordinateClothes() async throws -> [TodayCoordinateClothEntity] { - try await repository.fetchTodayCoordinateClothes() - } - + /// 코디 수동 생성 func createManualCoordinate( request: CreateManualCoordinateAPIRequestDTO diff --git a/Codive/Features/LookBook/Presentation/Component/LookBookEventManager.swift b/Codive/Features/LookBook/Presentation/Component/LookBookEventManager.swift new file mode 100644 index 00000000..a2e642ee --- /dev/null +++ b/Codive/Features/LookBook/Presentation/Component/LookBookEventManager.swift @@ -0,0 +1,17 @@ +// +// LookBookEventManager.swift +// Codive +// +// Created by 한금준 on 2/4/26. +// + +import Combine + +final class LookBookEventManager { + static let shared = LookBookEventManager() + private init() {} + + // PassthroughSubject 대신 CurrentValueSubject 사용 + // 초기값은 false로 설정 + let shouldShowAddDialog = CurrentValueSubject(false) +} diff --git a/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift b/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift index a93d325b..a80ab70d 100644 --- a/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift +++ b/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift @@ -73,14 +73,14 @@ private extension AddBeforeCodiView { ), spacing: 16 ) { - ForEach(viewModel.beforeCoordinateDailyList) { lookbook in + ForEach(viewModel.beforeCoordinateDailyList) { coordi in BeforeCodiCard( - imageURL: lookbook.imageUrl, - date: lookbook.date, + imageURL: coordi.imageUrl, + date: coordi.date, isSelected: false ) .onTapGesture { - viewModel.toggleSelection(id: Int(lookbook.id)) + viewModel.toggleSelection(id: coordi.id) } } } diff --git a/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift b/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift index 521d977d..d3b2aad4 100644 --- a/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift +++ b/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift @@ -55,7 +55,6 @@ struct AddCodiDetailView: View { GeometryReader { geometry in let boardSize = geometry.size.width - 40 - let imageHalfSize: CGFloat = 40 ZStack(alignment: .bottom) { VStack(spacing: 20) { diff --git a/Codive/Features/LookBook/Presentation/View/AddCodiView.swift b/Codive/Features/LookBook/Presentation/View/AddCodiView.swift index 772d352e..3ba12501 100644 --- a/Codive/Features/LookBook/Presentation/View/AddCodiView.swift +++ b/Codive/Features/LookBook/Presentation/View/AddCodiView.swift @@ -132,12 +132,14 @@ private extension AddCodiView { .resizable() .scaledToFill() - EditCodiOverlayView() - .clipShape(RoundedRectangle(cornerRadius: 12)) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.handleEditCodiTap() - } + if !viewModel.isPastCodiSelected { + EditCodiOverlayView() + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.handleEditCodiTap() + } + } } .frame(height: 335) .clipShape(RoundedRectangle(cornerRadius: 12)) diff --git a/Codive/Features/LookBook/Presentation/ViewModel/AddBeforeCodiViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/AddBeforeCodiViewModel.swift index a78b860d..9ff8d185 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/AddBeforeCodiViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/AddBeforeCodiViewModel.swift @@ -46,17 +46,17 @@ final class AddBeforeCodiViewModel: ObservableObject { size: 20, direction: .DESC ) - + self.beforeCoordinateDailyList = result.content } catch { handleError(error) } - + isLoading = false } } - - func toggleSelection(id: Int) { + + func toggleSelection(id: Int64) { guard let selectedCodi = beforeCoordinateDailyList.first(where: { $0.id == id }) else { return } navigateToAddCodiWithData(codi: selectedCodi) } @@ -66,6 +66,7 @@ final class AddBeforeCodiViewModel: ObservableObject { } private func navigateToAddCodiWithData(codi: BeforeCoordinateDailyEntity) { + AddCodiViewModel.beforeCodiSelected.send(codi) navigationRouter.navigateBack() } } diff --git a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift index ccae3409..5d8f0f92 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift @@ -195,7 +195,6 @@ final class AddCodiDetailViewModel: ObservableObject { rotation: payload.degree ) } - } func handleBackTap() { navigationRouter.navigateBack() } diff --git a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiViewModel.swift index 79f35d85..889d6109 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiViewModel.swift @@ -25,12 +25,16 @@ final class AddCodiViewModel: ObservableObject { @Published var isShowingSuccessView: Bool = false @Published var successMessage: String = "" + @Published var isPastCodiSelected: Bool = false + private var selectedTodayCoordinateId: Int64? + private var cancellables = Set() private let navigationRouter: NavigationRouter private let codiUseCase: CodiUseCase - let coordinateId: Int64 + let lookBookId: Int64 static let editCodiRequested = CurrentValueSubject(nil) + static let beforeCodiSelected = PassthroughSubject() var isButtonEnabled: Bool { let hasImage = capturedImage != nil || (selectedImageURL != nil && !selectedImageURL!.isEmpty) @@ -38,10 +42,10 @@ final class AddCodiViewModel: ObservableObject { } // MARK: - Initializer - init(navigationRouter: NavigationRouter, codiUseCase: CodiUseCase, coordinateId: Int64) { + init(navigationRouter: NavigationRouter, codiUseCase: CodiUseCase, lookBookId: Int64) { self.navigationRouter = navigationRouter self.codiUseCase = codiUseCase - self.coordinateId = coordinateId + self.lookBookId = lookBookId setupDataSubscription() } @@ -56,6 +60,18 @@ private extension AddCodiViewModel { .sink { [weak self] data in self?.receivedPayloads = data.payloads self?.selectedImageURL = data.imageString + self?.isPastCodiSelected = false + self?.selectedTodayCoordinateId = nil + } + .store(in: &cancellables) + + AddCodiViewModel.beforeCodiSelected + .receive(on: DispatchQueue.main) + .sink { [weak self] entity in + self?.selectedImageURL = entity.imageUrl + self?.selectedTodayCoordinateId = entity.id + self?.isPastCodiSelected = true + self?.capturedImage = nil } .store(in: &cancellables) } @@ -71,6 +87,36 @@ extension AddCodiViewModel { } func handleCompleteTap() { + if isPastCodiSelected { + createAutoCoordinate() + } else { + createManualCoordinate() + } + } + + /// [추가] 과거 코디를 이용한 자동 생성 로직 + private func createAutoCoordinate() { + guard let dailyId = selectedTodayCoordinateId else { return } + + let request = CreateAutoDailyCoordinateAPIRequestDTO( + name: codiName, + memo: memo, + dailyCoordinateId: dailyId, + lookBookId: lookBookId + ) + + Task { + do { + _ = try await codiUseCase.createAutoDailyCoordinate(request: request) + processSuccess() + } catch { + print("❌ 자동 코디 생성 실패: \(error.localizedDescription)") + } + } + } + + /// [기존 로직 분리] 수동 생성 로직 + private func createManualCoordinate() { guard isButtonEnabled, let imageURL = selectedImageURL else { return } @@ -79,25 +125,31 @@ extension AddCodiViewModel { coordinateImageUrl: imageURL, name: codiName, memo: memo, - lookBookId: Int64(coordinateId), + lookBookId: lookBookId, payloads: receivedPayloads ) Task { do { _ = try await codiUseCase.createManualCoordinate(request: requestDTO) - - self.successMessage = TextLiteral.LookBook.alertSuccessPostCoordi - self.isShowingSuccessView = true - - try? await Task.sleep(nanoseconds: 1_500_000_000) - self.isShowingSuccessView = false - self.navigationRouter.navigateBack() + processSuccess() } catch { - print("❌ 상세 에러 메시지: \(error.localizedDescription)") + print("❌ 수동 코디 생성 실패: \(error.localizedDescription)") } } } + + /// 성공 공통 처리 + private func processSuccess() { + self.successMessage = TextLiteral.LookBook.alertSuccessPostCoordi + self.isShowingSuccessView = true + + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + self.isShowingSuccessView = false + self.navigationRouter.navigateBack() + } + } func handleEditCodiTap() { guard let imageURL = selectedImageURL else { @@ -118,10 +170,11 @@ extension AddCodiViewModel { func navigateToNewCodi() { isShowingBottomSheet = false navigationRouter.navigate(to: .addCodiDetail) + self.isPastCodiSelected = false } func handleRecallCodi() { isShowingBottomSheet = false - navigationRouter.navigate(to: .addBeforeCodi(lookbookId: coordinateId)) + navigationRouter.navigate(to: .addBeforeCodi(lookbookId: lookBookId)) } } diff --git a/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift index adbce4dc..3a265f70 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine @MainActor final class LookBookViewModel: ObservableObject { @@ -15,6 +16,8 @@ final class LookBookViewModel: ObservableObject { let navigationRouter: NavigationRouter private let listUseCase: LookBookMainUseCase + private var cancellables = Set() + // MARK: - Published State (Data) @Published var lookBookList: [LookBookEntity] = [] @@ -40,6 +43,21 @@ final class LookBookViewModel: ObservableObject { ) { self.navigationRouter = navigationRouter self.listUseCase = listUseCase + + setupBindings() + } + + private func setupBindings() { + LookBookEventManager.shared.shouldShowAddDialog + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldShow in + if shouldShow { + self?.isShowingAddDialog = true + // ⚠️ 한 번 띄웠으면 다시 초기화해주어야 다음 진입 시 중복으로 뜨지 않습니다. + LookBookEventManager.shared.shouldShowAddDialog.send(false) + } + } + .store(in: &cancellables) } // MARK: - 룩북 전체 조회 @@ -193,3 +211,12 @@ final class LookBookViewModel: ObservableObject { ) } } + +extension UIImage { + func toBase64String() -> String? { + guard let data = self.jpegData(compressionQuality: 0.9) else { + return nil + } + return data.base64EncodedString() + } +} diff --git a/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift index f3cef8c6..dfe2b51f 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift @@ -202,7 +202,7 @@ final class SpecificLookBookViewModel: ObservableObject { } func navigateToAddCodi() { - navigationRouter.navigate(to: .addCodi(coordinateId: lookbookId)) + navigationRouter.navigate(to: .addCodi(lookBookId: lookbookId)) } func navigateToCodiDetail(codiId: Int) { diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 094921a6..765016e2 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -120,8 +120,10 @@ struct MainTabView: View { isPresented: $homeViewModel.showCompletePopUp, onRecordTapped: homeViewModel.handlePopupRecord, onCloseTapped: homeViewModel.handlePopupClose, - selectedClothes: homeViewModel.selectedCodiClothes + selectedClothes: homeViewModel.selectedCodiClothes, + singleImageUrl: homeViewModel.capturedImageURL ) + .id(homeViewModel.capturedImageURL) .zIndex(200) } } @@ -200,7 +202,7 @@ struct MainTabView: View { case .codiBoard: homeDIContainer.makeCodiBoardView() case .favoriteCodiList(let showHeart): - FavoriteCodiView(showHeart: showHeart, navigationRouter: navigationRouter) + profileDIContainer.makeFavoriteCodiView(showHeart: showHeart) case .settings: settingDIContainer.makeSettingView() case .settingLikedRecords: diff --git a/Codive/Features/Notification/Data/DTOs/NotificationDTO.swift b/Codive/Features/Notification/Data/DTOs/NotificationDTO.swift index 24a1bc2c..ba450fc6 100644 --- a/Codive/Features/Notification/Data/DTOs/NotificationDTO.swift +++ b/Codive/Features/Notification/Data/DTOs/NotificationDTO.swift @@ -7,51 +7,48 @@ import Foundation +enum NotificationType: String { + case follow = "FOLLOW" + case followRequest = "FOLLOW_REQUEST" + case comment = "COMMENT" + case reply = "REPLY" + case like = "LIKE" + case temperatureDaily = "TEMPERATURE_DAILY" + case unknown = "UNKNOWN" +} + +enum ReadStatus: String { + case read = "READ" + case notRead = "NOT_READ" +} + +enum NotificationRedirectType: String { + case none = "NONE" + case historyRedirect = "HISTORY_REDIRECT" + case memberRedirect = "MEMBER_REDIRECT" +} + +// MARK: - DTO + /// 알림 목록 조회 struct NotificationListResponseDTO { let content: [NotificationListResponseItem] let isLast: Bool } +struct NotificationActionDTO { + let redirectType: NotificationRedirectType + let redirectInfo: String +} + struct NotificationListResponseItem { let notificationId: Int64 let notificationImageUrl: String let notificationContent: String - let redirectInfo: String - let redirectType: String - let readStatus: String + let notificationType: NotificationType + let action: NotificationActionDTO + let readStatus: ReadStatus let createdAt: String - - enum CodingKeys: String, CodingKey { - case notificationId - case notificationImageUrl - case notificationContent - case redirectInfo - case redirectType - case readStatus - case createdAt - } - - private func parseDate(_ dateString: String) -> Date { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [ - .withInternetDateTime, - .withFractionalSeconds - ] - return formatter.date(from: dateString) ?? Date() - } - - func toEntity() -> NotificationEntity { - return NotificationEntity( - notificationId: notificationId, - notificationImageUrl: notificationImageUrl, - notificationContent: notificationContent, - redirectInfo: redirectInfo, - redirectType: RedirectType(rawValue: redirectType) ?? .member, - readStatus: ReadStatus(rawValue: readStatus) ?? .unread, - createdAt: createdAt - ) - } } struct NotificationExistAPIResponseDTO { diff --git a/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift b/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift index 1320711b..2bdcab62 100644 --- a/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift +++ b/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift @@ -23,17 +23,14 @@ final class NotificationRepositoryImpl: NotificationRepository { try await datasource.patchAllNotification() } - func fetchNotificationList(lastNotificationId: Int64?, size: Int32) async throws -> (content: [NotificationEntity], isLast: Bool) { - let dto = try await datasource.fetchNotificationList( + func fetchNotificationList( + lastNotificationId: Int64?, + size: Int32 + ) async throws -> NotificationListResponseDTO { + return try await datasource.fetchNotificationList( lastNotificationId: lastNotificationId, size: size ) - - - return ( - content: dto.content.map { $0.toEntity() }, - isLast: dto.isLast - ) } func fetchNotificationExist() async throws -> NotificationExistAPIResponseDTO { diff --git a/Codive/Features/Notification/Data/Services/NotificationAPIService.swift b/Codive/Features/Notification/Data/Services/NotificationAPIService.swift index c43db2aa..e665b5ac 100644 --- a/Codive/Features/Notification/Data/Services/NotificationAPIService.swift +++ b/Codive/Features/Notification/Data/Services/NotificationAPIService.swift @@ -25,10 +25,10 @@ protocol NotificationAPIServiceProtocol { } final class NotificationAPIService: NotificationAPIServiceProtocol { - + private let client: Client private let jsonDecoder: JSONDecoder - + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] @@ -41,7 +41,7 @@ extension NotificationAPIService { func patchEachNotification(notificationId: Int64) async throws { let input = Operations.updateReadStatus.Input(path: .init(notificationId: notificationId)) let response = try await client.updateReadStatus(input) - + switch response { case .ok: return @@ -64,18 +64,11 @@ extension NotificationAPIService { } extension NotificationAPIService { - private func formatDate(_ date: Date?) -> String { - guard let date else { return "" } + func fetchNotificationList( + lastNotificationId: Int64?, + size: Int32 + ) async throws -> NotificationListResponseDTO { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [ - .withInternetDateTime, - .withFractionalSeconds - ] - return formatter.string(from: date) - } - - func fetchNotificationList(lastNotificationId: Int64?, size: Int32) async throws -> NotificationListResponseDTO { let input = Operations.Notification_getNotificationList.Input( query: .init( lastNotificationId: lastNotificationId, @@ -87,26 +80,32 @@ extension NotificationAPIService { switch response { case .ok(let okResponse): - let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let data = try await Data( + collecting: okResponse.body.any, + upTo: .max + ) + + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseNotificationListResponse.self, + from: data + ) - let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseNotificationListResponse.self, from: data) + guard let result = decoded.result else { + throw NotificationAPIError.invalidResponse + } - let content: [NotificationListResponseItem] = decoded.result?.content?.map { item -> NotificationListResponseItem in - return NotificationListResponseItem( - notificationId: item.notificationId ?? 0, - notificationImageUrl: item.notificationImageUrl ?? "", - notificationContent: item.notificationContent ?? "", - redirectInfo: item.redirectInfo ?? "", - redirectType: item.redirectType?.rawValue ?? "", - readStatus: item.readStatus?.rawValue ?? "", - createdAt: formatDate(item.createdAt) - ) - } ?? [] + let content = mapNotificationItems(result.content) - return NotificationListResponseDTO(content: content, isLast: decoded.result?.isLast ?? true) + return NotificationListResponseDTO( + content: content, + isLast: result.isLast ?? true + ) case .undocumented(statusCode: let code, _): - throw NotificationAPIError.serverError(statusCode: code, message: "개별 룩북 코디 목록 조회 실패") + throw NotificationAPIError.serverError( + statusCode: code, + message: "알림 목록 조회 실패" + ) } } @@ -124,7 +123,7 @@ extension NotificationAPIService { guard let item = decoded.result else { throw NotificationAPIError.invalidResponse } - + return ReportReceivedAPIResponseDTO( isReported: item.isReported ?? false, targetType: item.targetType.flatMap { ReportType(rawValue: $0.rawValue) } @@ -137,7 +136,6 @@ extension NotificationAPIService { } extension NotificationAPIService { - func fetchNotificationExist() async throws -> NotificationExistAPIResponseDTO { let input = Operations.Notification_existsUnreadNotification.Input() @@ -163,6 +161,50 @@ extension NotificationAPIService { } } + +extension NotificationAPIService { + private func mapNotificationItems( + _ items: [Components.Schemas.NotificationListResponse]? + ) -> [NotificationListResponseItem] { + items?.map { item in + let notificationType = NotificationType( + rawValue: item.notificationType?.rawValue ?? "" + ) ?? .unknown + + let readStatus = ReadStatus( + rawValue: item.readStatus?.rawValue ?? "" + ) ?? .notRead + + let redirectType = NotificationRedirectType( + rawValue: item.action?.redirectType?.rawValue ?? "" + ) ?? .none + + return NotificationListResponseItem( + notificationId: item.notificationId ?? 0, + notificationImageUrl: item.notificationImageUrl ?? "", + notificationContent: item.notificationContent ?? "", + notificationType: notificationType, + action: NotificationActionDTO( + redirectType: redirectType, + redirectInfo: item.action?.redirectInfo ?? "" + ), + readStatus: readStatus, + createdAt: formatDate(item.createdAt) + ) + } ?? [] + } + + private func formatDate(_ date: Date?) -> String { + guard let date else { return "" } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [ + .withInternetDateTime, + .withFractionalSeconds + ] + return formatter.string(from: date) + } +} // MARK: - ClothAPIError enum NotificationAPIError: LocalizedError { @@ -172,7 +214,7 @@ enum NotificationAPIError: LocalizedError { case s3UploadFailed(statusCode: Int) case noClothIdsReturned case serverError(statusCode: Int, message: String) - + var errorDescription: String? { switch self { case .presignedUrlMismatch: diff --git a/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift b/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift index c8703fb1..875a59a2 100644 --- a/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift +++ b/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift @@ -7,32 +7,6 @@ import Foundation -/// 알림 유형 -enum RedirectType: String, Codable { - case member = "MEMBER_REDIRECT" - case history = "HISTORY_REDIRECT" - case weather = "WEATHER_REDIRECT" -} - -/// 알림 읽음/읽지 않음 유형 -enum ReadStatus: String, Codable { - case read = "READ" - case unread = "NOT_READ" -} - -/// 알림 목록 조회 api -struct NotificationEntity: Codable, Identifiable { - let notificationId: Int64 - let notificationImageUrl: String? - let notificationContent: String - let redirectInfo: String - let redirectType: RedirectType - var readStatus: ReadStatus - let createdAt: String - - var id: Int64 { notificationId } -} - /// 신고 접수 유형 enum ReportType: String, Codable { case HISTORY = "HISTORY" diff --git a/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift b/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift index 7c57e383..b5c3bfee 100644 --- a/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift +++ b/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift @@ -8,7 +8,7 @@ protocol NotificationRepository { func patchEachNotification(notificationId: Int64) async throws func patchAllNotification() async throws - func fetchNotificationList(lastNotificationId: Int64?, size: Int32) async throws -> (content: [NotificationEntity], isLast: Bool) + func fetchNotificationList(lastNotificationId: Int64?, size: Int32) async throws -> NotificationListResponseDTO func fetchNotificationExist() async throws -> NotificationExistAPIResponseDTO func fetchReportReceived() async throws -> ReportReceivedAPIResponseDTO } diff --git a/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift b/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift index 750b6abc..fad81978 100644 --- a/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift +++ b/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift @@ -23,7 +23,7 @@ final class NotificationUseCase { try await repository.patchAllNotification() } - func fetchNotificationList(lastNotificationId: Int64?, size: Int32) async throws -> (content: [NotificationEntity], isLast: Bool) { + func fetchNotificationList(lastNotificationId: Int64?, size: Int32) async throws -> NotificationListResponseDTO { return try await repository.fetchNotificationList( lastNotificationId: lastNotificationId, size: size @@ -33,8 +33,4 @@ final class NotificationUseCase { func fetchReportReceived() async throws -> ReportReceivedAPIResponseDTO { return try await repository.fetchReportReceived() } - -// func fetchReportStatus() -> ReportEntity { -// return false -// } } diff --git a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift index c28d116d..e3bf230f 100644 --- a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift +++ b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift @@ -8,37 +8,39 @@ import SwiftUI struct NotificationRow: View { - let entity: NotificationEntity + let notification: NotificationListResponseItem private let profileImageSize: CGFloat = 36 - // MARK: - Asset Logic - /// 타입별 전용 에셋 이미지 이름 반환 + // MARK: - RedirectType 기반 에셋 매핑 private var typeSpecificImageName: String? { - switch entity.redirectType { - case .history: + switch notification.action.redirectType { + case .historyRedirect: return "history" - case .weather: - return "weather" - case .member: + case .memberRedirect: return nil + case .none: + return "weather" } } var body: some View { HStack(spacing: 15) { + if let imageName = typeSpecificImageName { Image(imageName) .resizable() .aspectRatio(contentMode: .fill) .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) - } else if let urlString = entity.notificationImageUrl, let url = URL(string: urlString) { + } else if !notification.notificationImageUrl.isEmpty, + let url = URL(string: notification.notificationImageUrl) { + AsyncImage(url: url) { phase in if let image = phase.image { image.resizable().aspectRatio(contentMode: .fill) } else if phase.error != nil { - defaultImage // URL 에러 시 기본 이미지 + defaultImage } else { ProgressView() } @@ -46,10 +48,10 @@ struct NotificationRow: View { .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } else { - defaultImage // 이미지 URL이 nil인 경우 + defaultImage } - Text(entity.notificationContent) + Text(notification.notificationContent) .font(Font.codive_body2_regular) .foregroundStyle(Color.Codive.grayscale1) diff --git a/Codive/Features/Notification/Presentation/View/NotificationView.swift b/Codive/Features/Notification/Presentation/View/NotificationView.swift index 4e0cefa1..0c2604b8 100644 --- a/Codive/Features/Notification/Presentation/View/NotificationView.swift +++ b/Codive/Features/Notification/Presentation/View/NotificationView.swift @@ -59,7 +59,11 @@ struct NotificationView: View { // MARK: - View Builders @ViewBuilder - private func notificationSection(title: String, notifications: [NotificationEntity]) -> some View { + private func notificationSection( + title: String, + notifications: [NotificationListResponseItem] + ) -> some View { + if !notifications.isEmpty { HStack { Text(title) @@ -68,16 +72,16 @@ struct NotificationView: View { Spacer() } .padding(.top, 20) - + VStack(spacing: 16) { - ForEach(notifications) { item in - NotificationRow(entity: item) - .contentShape(Rectangle()) // 투명한 영역도 탭이 되도록 설정 + ForEach(notifications, id: \.notificationId) { item in + NotificationRow(notification: item) + .contentShape(Rectangle()) .onTapGesture { - if item.readStatus == .unread { + if item.readStatus == .notRead { viewModel.markAsRead(notificationId: item.notificationId) } - // 페이지 이동 로직 호출 구간 + // redirect 처리 위치 } } } diff --git a/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift b/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift index 7254bfbd..dc3b05b8 100644 --- a/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift +++ b/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift @@ -13,8 +13,8 @@ final class NotificationViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let useCase: NotificationUseCase - @Published var unreadNotifications: [NotificationEntity] = [] - @Published var readNotifications: [NotificationEntity] = [] + @Published var unreadNotifications: [NotificationListResponseItem] = [] + @Published var readNotifications: [NotificationListResponseItem] = [] @Published var isReported: Bool = false @Published var reportType: ReportType? @@ -34,21 +34,14 @@ final class NotificationViewModel: ObservableObject { lastNotificationId: nil, size: 20 ) - + updateNotificationLists(result.content) - + let reportResult = try await useCase.fetchReportReceived() self.isReported = reportResult.isReported if reportResult.isReported { - switch reportResult.targetType { - case .COMMENT: - self.reportType = .COMMENT - case .HISTORY: - self.reportType = .HISTORY - case .none: - self.reportType = nil - } + self.reportType = reportResult.targetType } else { self.reportType = nil } @@ -65,20 +58,26 @@ final class NotificationViewModel: ObservableObject { if let index = unreadNotifications.firstIndex(where: { $0.notificationId == notificationId }) { var readItem = unreadNotifications.remove(at: index) - readItem.readStatus = .read + readItem = NotificationListResponseItem( + notificationId: readItem.notificationId, + notificationImageUrl: readItem.notificationImageUrl, + notificationContent: readItem.notificationContent, + notificationType: readItem.notificationType, + action: readItem.action, + readStatus: .read, + createdAt: readItem.createdAt + ) readNotifications.insert(readItem, at: 0) } - - print("✅ Notification marked as read:", notificationId) } catch { - readErrorMessage = "알림 읽음 처리에 실패했어요. 잠시 후 다시 시도해 주세요." - print("❌ Notification markAsRead failed:", error)} + readErrorMessage = "알림 읽음 처리에 실패했어요." + } } } - private func updateNotificationLists(_ all: [NotificationEntity]) { - self.unreadNotifications = all.filter { $0.readStatus == .unread } - self.readNotifications = all.filter { $0.readStatus == .read } + private func updateNotificationLists(_ all: [NotificationListResponseItem]) { + unreadNotifications = all.filter { $0.readStatus == .notRead } + readNotifications = all.filter { $0.readStatus == .read } } // MARK: - Navigation diff --git a/Codive/Features/Profile/MyProfile/Data/DTOs/MyFavoriteLookBookDTO.swift b/Codive/Features/Profile/MyProfile/Data/DTOs/MyFavoriteLookBookDTO.swift new file mode 100644 index 00000000..0fe82307 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/DTOs/MyFavoriteLookBookDTO.swift @@ -0,0 +1,12 @@ +// +// MyFavoriteLookBookDTO.swift +// Codive +// +// Created by 한금준 on 2/4/26. +// + +public struct MyFavoriteLookBookResponseDTO { + let coordinateId: Int64 + let imageUrl: String + let coordinateName: String +} diff --git a/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift index a808ffcb..7a94879e 100644 --- a/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift +++ b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift @@ -13,6 +13,14 @@ protocol ProfileDataSourceProtocol { func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo func checkNicknameDuplicate(nickname: String) async throws -> Bool func uploadProfileImage(_ imageData: Data) async throws -> String + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] + /// 코디 preview 조회 + func fetchCoordinatePreview(coordinateId: Int64) async throws -> CoordinatePreviewResponseDTO + + /// 코디 detail 조회 + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailResponseDTO] } final class ProfileDataSource: ProfileDataSourceProtocol { @@ -41,4 +49,18 @@ final class ProfileDataSource: ProfileDataSourceProtocol { func uploadProfileImage(_ imageData: Data) async throws -> String { return try await apiService.uploadProfileImage(imageData) } + + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] { + return try await apiService.fetchMyFavoriteCoordinate() + } + + func fetchCoordinatePreview(coordinateId: Int64) async throws -> CoordinatePreviewResponseDTO { + return try await apiService.fetchCoordinatePreview(coordinateId: coordinateId) + } + + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailResponseDTO] { + return try await apiService.fetchCoordinateDetail(coordinateId: coordinateId) + } } diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 1810fb8d..c7d8aadc 100644 --- a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -18,6 +18,11 @@ protocol ProfileAPIServiceProtocol { func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo func checkNicknameDuplicate(nickname: String) async throws -> Bool func uploadProfileImage(_ imageData: Data) async throws -> String + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] + func fetchCoordinatePreview(coordinateId: Int64) async throws -> CoordinatePreviewResponseDTO + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailResponseDTO] } // MARK: - Profile API Service Implementation @@ -230,6 +235,106 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { } } +extension ProfileAPIService { + /// 나의 최애 코디 조회 + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] { + let input = Operations.Coordinate_getFavoriteCoordinates.Input() + let response = try await client.Coordinate_getFavoriteCoordinates(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseListFavoriteCoordinateResponse.self, from: data) + + let items = decoded.result ?? [] + + return items.map { item in + MyFavoriteLookBookResponseDTO( + coordinateId: item.coordinateId ?? 0, + imageUrl: item.imageUrl ?? "", + coordinateName: item.coordinateName ?? "" + ) + } + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "최애 코디 조회 실패 (상태코드: \(code))") + } + } + + func fetchCoordinatePreview(coordinateId: Int64) async throws -> CoordinatePreviewResponseDTO { + let input = Operations.Coordinate_getCoordinatePreview.Input( + path: .init( + coordinateId: coordinateId + ) + ) + + let response = try await client.Coordinate_getCoordinatePreview(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.BaseResponseCoordinatePreviewResponse.self, from: data) + + guard let item = decoded.result else { + throw LookBookAPIError.invalidResponse + } + + return CoordinatePreviewResponseDTO( + coordinateId: item.coordinateId ?? 0, + imageUrl: item.imageUrl ?? "", + coordinateName: item.coordinateName ?? "", + coordinateMemo: item.coordinateMemo ?? "" + ) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "코디 preview 조회 실패") + } + } + + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailResponseDTO] { + let input = Operations.Coordinate_getCoordinateDetails.Input( + path: .init( + coordinateId: coordinateId + ) + ) + + let response = try await client.Coordinate_getCoordinateDetails(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.BaseResponseListCoordinateDetailsListResponse.self, from: data) + + let items = decoded.result ?? [] + + return items.map { item in + CoordinateDetailResponseDTO( + coordinateClothId: item.coordinateClothId ?? 0, + locationX: item.locationX ?? 0, + locationY: item.locationY ?? 0, + ratio: item.ratio ?? 1.0, + degree: item.degree ?? 0, + order: item.order ?? 0, + clothId: item.clothId ?? 0, + imageUrl: item.imageUrl ?? "", + brand: item.brand ?? "", + name: item.name ?? "", + category: item.category ?? "", + parentCategory: item.parentCategory ?? "" + ) + } + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "코디 detail 조회 실패") + } + } +} + // MARK: - Private Helpers private extension ProfileAPIService { diff --git a/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift index 038d1328..125d9973 100644 --- a/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift +++ b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift @@ -33,4 +33,24 @@ final class ProfileRepositoryImpl: ProfileRepository { func uploadProfileImage(_ imageData: Data) async throws -> String { return try await dataSource.uploadProfileImage(imageData) } + + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] { + return try await dataSource.fetchMyFavoriteCoordinate() + } + + func fetchCoordinatePreview(coordinateId: Int64) async throws -> CoordinatePreviewEntity { + let dto = try await dataSource.fetchCoordinatePreview(coordinateId: coordinateId) + return dto.toEntity() + } + + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailEntity] { + + let dtoList = try await dataSource.fetchCoordinateDetail( + coordinateId: coordinateId + ) + + return dtoList.map { $0.toEntity() } + } } diff --git a/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift index 7cf44a82..9fab7965 100644 --- a/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift +++ b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift @@ -13,4 +13,9 @@ protocol ProfileRepository { func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo func checkNicknameDuplicate(nickname: String) async throws -> Bool func uploadProfileImage(_ imageData: Data) async throws -> String + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] + func fetchCoordinatePreview(coordinateId: Int64) async throws -> CoordinatePreviewEntity + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailEntity] } diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyFavoriteLookBookUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyFavoriteLookBookUseCase.swift new file mode 100644 index 00000000..645cccb4 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyFavoriteLookBookUseCase.swift @@ -0,0 +1,33 @@ +// +// FetchMyFavoriteLookBookUseCase.swift +// Codive +// +// Created by 한금준 on 2/5/26. +// + +final class FetchMyFavoriteLookBookUseCase { + // MARK: - Properties + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func fetchMyFavoriteCoordinate() async throws -> [MyFavoriteLookBookResponseDTO] { + try await repository.fetchMyFavoriteCoordinate() + } + + /// 코디 preview 조회 + func fetchCoordinatePreview( + coordinateId: Int64 + ) async throws -> CoordinatePreviewEntity { + try await repository.fetchCoordinatePreview(coordinateId: coordinateId) + } + + /// 코디 detail 조회 + func fetchCoordinateDetail( + coordinateId: Int64 + ) async throws -> [CoordinateDetailEntity] { + try await repository.fetchCoordinateDetail(coordinateId: coordinateId) + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 0f7d4c80..2c12bf87 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -11,45 +11,59 @@ import SwiftUI struct ProfileView: View { @ObservedObject private var navigationRouter: NavigationRouter @ObservedObject private var viewModel: ProfileViewModel - + init(viewModel: ProfileViewModel, navigationRouter: NavigationRouter) { self._viewModel = ObservedObject(wrappedValue: viewModel) self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } - + var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - topBar - - profileSection - .padding(.top, 32) - - Divider() - .padding(.top, 24) - .foregroundStyle(Color.Codive.grayscale7) - - favoriteCodiSection - .padding(.top, 24) - - calendarSection - .padding(.top, 40) - - Spacer(minLength: 40) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + topBar + + profileSection + .padding(.top, 32) + + Divider() + .padding(.top, 24) + .foregroundStyle(Color.Codive.grayscale7) + + favoriteCodiSection + .padding(.top, 24) + + calendarSection + .padding(.top, 40) + + Spacer(minLength: 40) + } + } + .background(Color.white) + .task { + await viewModel.loadMyProfile() + } + + if viewModel.isShowingPopup, let preview = viewModel.selectedCoordinatePreview { + FavoriteLookBookPopUp( + imageUrl: preview.imageUrl, + clothItems: viewModel.popupClothItems, + payloads: viewModel.popupPayloads + ) { + viewModel.isShowingPopup = false + } + .transition(.opacity.combined(with: .scale)) + .zIndex(1) } - } - .background(Color.white) - .task { - await viewModel.loadMyProfile() } } - + // MARK: - Top Bar private var topBar: some View { HStack(spacing: 12) { - + Spacer(minLength: 0) - + Button { viewModel.onEditProfileTapped() } label: { @@ -58,7 +72,7 @@ struct ProfileView: View { .scaledToFit() .frame(width: 20, height: 20) } - + Button { viewModel.onSettingsTapped() } label: { @@ -70,7 +84,7 @@ struct ProfileView: View { } .padding(.horizontal, 20) } - + // MARK: - Profile private var profileSection: some View { VStack { @@ -104,12 +118,12 @@ struct ProfileView: View { .frame(width: 80, height: 80) .clipShape(Circle()) } - + Text(viewModel.displayName) .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) .padding(.top, 9) - + HStack(spacing: 20) { Button { viewModel.onFollowerTapped() @@ -123,7 +137,7 @@ struct ProfileView: View { .foregroundStyle(Color.Codive.grayscale1) } } - + Button { viewModel.onFollowingTapped() } label: { @@ -138,7 +152,7 @@ struct ProfileView: View { } } .padding(.top, 4) - + Text(viewModel.introText) .font(.codive_body2_regular) .foregroundStyle(Color.Codive.grayscale4) @@ -146,7 +160,7 @@ struct ProfileView: View { } .frame(maxWidth: .infinity) } - + // MARK: - Favorite Codi private var favoriteCodiSection: some View { VStack(alignment: .leading, spacing: 12) { @@ -154,9 +168,9 @@ struct ProfileView: View { Text("최애 코디") .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) - + Spacer(minLength: 0) - + Button { viewModel.onMoreFavoriteCodiTapped() } label: { @@ -171,29 +185,40 @@ struct ProfileView: View { } } .padding(.horizontal, 20) - + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { - ForEach(0..<8, id: \.self) { _ in + ForEach(viewModel.favoriteCoordinates, id: \.coordinateId) { codi in CodiCard( - imageURL: URL(string: "https://via.placeholder.com/160/F08080/FFFFFF?text=Date+Look"), + imageURL: URL(string: codi.imageUrl), title: nil, - icon: .heart(isSelected: true, onTap: nil), // 항상 하트 on, 타이틀 없음 + icon: .heart(isSelected: true, onTap: {}), cardWidth: 160, imageSize: 160, cornerRadius: 16, iconPadding: 14, iconSize: 20, - onCardTap: nil + onCardTap: { + // 카드 클릭 시 coordinateId 전달 + viewModel.onCodiCardTapped(coordinateId: Int64(codi.coordinateId)) + } ) } + + if viewModel.favoriteCoordinates.isEmpty { + ForEach(0..<3) { _ in + RoundedRectangle(cornerRadius: 16) + .fill(Color.Codive.grayscale7) + .frame(width: 160, height: 160) + } + } } .padding(.top, 12) } .padding(.horizontal, 20) } } - + // MARK: - Calendar private var calendarSection: some View { VStack(alignment: .leading, spacing: 12) { @@ -201,23 +226,19 @@ struct ProfileView: View { .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) .padding(.horizontal, 20) - + CalendarMonthView( month: $viewModel.month, selectedDate: $viewModel.selectedDate, monthlyHistories: $viewModel.monthlyHistories ) - .padding(16) - .frame(maxWidth: .infinity, alignment: .center) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .codiveCardShadow() - .padding(.horizontal, 20) - .padding(.top, 12) + .padding(16) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .codiveCardShadow() + .padding(.horizontal, 20) + .padding(.top, 12) } } } - -#Preview { - EmptyView() -} diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index 38bd694d..dfa943c2 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -18,7 +18,37 @@ class ProfileViewModel: ObservableObject { @Published var followingCount: Int = 0 @Published var profileImageUrl: String? @Published var email: String? - + @Published var favoriteCoordinates: [MyFavoriteLookBookResponseDTO] = [] + + @Published var isShowingPopup: Bool = false + @Published var selectedCoordinatePreview: CoordinatePreviewEntity? + @Published var selectedCoordinateDetails: [CoordinateDetailEntity] = [] + + var popupClothItems: [CodiItem] { + selectedCoordinateDetails.map { detail in + CodiItem( + id: detail.coordinateClothId, + imageName: detail.imageUrl, + brand: detail.brand, + name: detail.name, + clothId: detail.clothId + ) + } + } + + var popupPayloads: [Payloads] { + selectedCoordinateDetails.map { detail in + Payloads( + clothId: detail.clothId, + locationX: detail.locationX, + locationY: detail.locationY, + ratio: detail.ratio, + degree: detail.degree, + order: detail.order + ) + } + } + // MARK: - State @Published var month: Date = Date() { didSet { @@ -31,28 +61,31 @@ class ProfileViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var monthlyHistories: [String: String] = [:] // "2026-01-21" -> imageUrl - + // MARK: - Dependencies private let navigationRouter: NavigationRouter private let fetchMyProfileUseCase: FetchMyProfileUseCase private let fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase - + private let fetchMyFavoriteLookBookUseCase: FetchMyFavoriteLookBookUseCase + // MARK: - Initializer init( navigationRouter: NavigationRouter, fetchMyProfileUseCase: FetchMyProfileUseCase, - fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase + fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase, + fetchMyFavoriteLookBookUseCase: FetchMyFavoriteLookBookUseCase ) { self.navigationRouter = navigationRouter self.fetchMyProfileUseCase = fetchMyProfileUseCase self.fetchMonthlyHistoryUseCase = fetchMonthlyHistoryUseCase + self.fetchMyFavoriteLookBookUseCase = fetchMyFavoriteLookBookUseCase } // MARK: - Loading func loadMyProfile() async { isLoading = true errorMessage = nil - + do { let profileInfo = try await fetchMyProfileUseCase.execute() self.userId = profileInfo.userId @@ -67,57 +100,80 @@ class ProfileViewModel: ObservableObject { self.errorMessage = error.localizedDescription print("프로필 로드 실패: \(error.localizedDescription)") } - + isLoading = false - + // 프로필 로드 후 캘린더 데이터 로드 await loadMonthlyHistories() + + await loadFavoriteCoordinates() } - + func loadMonthlyHistories() async { guard userId != 0 else { return } - + let calendar = Calendar.current let year = Int32(calendar.component(.year, from: month)) let monthValue = Int32(calendar.component(.month, from: month)) - + do { let items = try await fetchMonthlyHistoryUseCase.execute( memberId: Int64(userId), year: year, month: monthValue ) - + // 같은 날짜에 여러 기록이 있으면 첫 번째만 사용 var historyMap: [String: String] = [:] for item in items where historyMap[item.historyDate] == nil { historyMap[item.historyDate] = item.firstImageUrl } - + self.monthlyHistories = historyMap } catch { print("월별 기록 로드 실패: \(error.localizedDescription)") } } - + // MARK: - Actions func onEditProfileTapped() { navigationRouter.navigate(to: .profileSetting) } - + func onSettingsTapped() { navigationRouter.navigate(to: .settings) } - + func onFollowerTapped() { navigationRouter.navigate(to: .followList(mode: .followers, memberId: userId)) } - + func onFollowingTapped() { navigationRouter.navigate(to: .followList(mode: .followings, memberId: userId)) } - + func onMoreFavoriteCodiTapped() { navigationRouter.navigate(to: .favoriteCodiList(showHeart: true)) } + + func loadFavoriteCoordinates() async { + do { + let coordinates = try await fetchMyFavoriteLookBookUseCase.fetchMyFavoriteCoordinate() + self.favoriteCoordinates = coordinates + } catch { + print("최애 코디 로드 실패: \(error)") + } + } + + func onCodiCardTapped(coordinateId: Int64) { + Task { + do { + self.selectedCoordinatePreview = try await fetchMyFavoriteLookBookUseCase.fetchCoordinatePreview(coordinateId: coordinateId) + self.selectedCoordinateDetails = try await fetchMyFavoriteLookBookUseCase.fetchCoordinateDetail(coordinateId: coordinateId) + self.isShowingPopup = true + } catch { + print("코디 상세 정보 로드 실패: \(error)") + } + } + } } diff --git a/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift b/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift new file mode 100644 index 00000000..470e4afa --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift @@ -0,0 +1,161 @@ +// +// FavoriteLookBookPopUp.swift +// Codive +// +// Created by 한금준 on 2/5/26. +// + +import SwiftUI + +struct FavoriteLookBookPopUp: View { + let imageUrl: String + let clothItems: [CodiItem] + let payloads: [Payloads] + var onClose: () -> Void + + @State private var showClothSelector: Bool = false + @State private var selectedIndex: Int = 0 + + var body: some View { + GeometryReader { outerGeo in + let popupWidth = outerGeo.size.width - 40 + + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { onClose() } + + VStack(spacing: 0) { + HStack { + Spacer() + Button { + onClose() + } label: { + Image(systemName: "xmark") + .foregroundColor(.black) + .padding(16) + } + } + + VStack(spacing: 16) { + let currentItem = clothItems[safe: selectedIndex] + let currentPayload = payloads.first { + $0.clothId == currentItem?.clothId + } + + codiDisplayArea( + width: popupWidth, + selectedItem: currentItem, + payload: currentPayload + ) + + if showClothSelector { + clothSelector + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .padding(.bottom, 24) + } + .background(Color.white) + .cornerRadius(20) + .padding(.horizontal, 20) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: showClothSelector) + } + } + } +} + +private extension FavoriteLookBookPopUp { + func codiDisplayArea(width: CGFloat, selectedItem: CodiItem?, payload: Payloads?) -> some View { + let boardSize = width - 40 + + return ZStack(alignment: .bottomLeading) { + ZStack { + RoundedRectangle(cornerRadius: 15) + .fill(Color.gray.opacity(0.1)) + + AsyncImage(url: URL(string: imageUrl)) { phase in + if let image = phase.image { + image.resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 15)) + } else { + ProgressView() + } + } + + // MARK: - 태그 표시 조건 수정 + if showClothSelector, let item = selectedItem, let pos = payload { + CustomTagView(type: .basic(title: item.brand, content: item.name)) + .position( + x: boardSize * CGFloat(pos.locationX), + y: boardSize * CGFloat(pos.locationY) + ) + .transition(.opacity.combined(with: .scale)) + .id(item.id) + } + } + .frame(width: boardSize, height: boardSize) + + Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + showClothSelector.toggle() + } + } label: { + Image("ic_tag") + .resizable() + .frame(width: 28, height: 28) + } + .padding([.leading, .bottom], 16) + } + } + + var clothSelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Array(clothItems.enumerated()), id: \.element.id) { index, item in + clothItemCell(at: index, item: item) + } + } + .padding(.horizontal, 20) + } + .frame(height: 80) + } + + func clothItemCell(at index: Int, item: CodiItem) -> some View { + AsyncImage(url: URL(string: item.imageName)) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 68, height: 68) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(selectedIndex == index ? Color.blue : Color.clear, lineWidth: 2) + ) + .onTapGesture { + selectedIndex = index + } + } +} + +extension Collection { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + +#Preview { + FavoriteLookBookPopUp( + imageUrl: "샘플이미지URL", + clothItems: [ + CodiItem(id: 1, imageName: "옷이미지1", brand: "아디다스", name: "팬츠", clothId: 501), + CodiItem(id: 2, imageName: "옷이미지2", brand: "나이키", name: "셔츠", clothId: 502) + ], + payloads: [ + Payloads(clothId: 501, locationX: 0.5, locationY: 0.7, ratio: 1.0, degree: 0, order: 1), + Payloads(clothId: 502, locationX: 0.5, locationY: 0.3, ratio: 1.0, degree: 0, order: 2) + ] + ) {} +} diff --git a/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift b/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift index 4e9e9f15..c9b704ba 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift @@ -9,19 +9,20 @@ import SwiftUI struct FavoriteCodiView: View { @ObservedObject private var navigationRouter: NavigationRouter + @ObservedObject private var viewModel: FavoriteCodiViewModel - let showHeart: Bool - private let columns: [GridItem] = [ GridItem(.flexible(), spacing: 15), GridItem(.flexible(), spacing: 15) ] - - init(showHeart: Bool, navigationRouter: NavigationRouter) { + let showHeart: Bool + + init(showHeart: Bool, viewModel: FavoriteCodiViewModel, navigationRouter: NavigationRouter) { self.showHeart = showHeart - self.navigationRouter = navigationRouter + self._viewModel = ObservedObject(wrappedValue: viewModel) + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } - + var body: some View { VStack(spacing: 0) { CustomNavigationBar( @@ -29,33 +30,37 @@ struct FavoriteCodiView: View { onBack: { navigationRouter.navigateBack() }, rightButton: .none ) - + ScrollView(showsIndicators: false) { - LazyVGrid(columns: columns, spacing: 32) { - ForEach(0..<8, id: \.self) { idx in - CodiCard( - imageURL: URL(string: "https://via.placeholder.com/160"), - title: idx.isMultiple(of: 2) ? "영화관 데이트" : "미술관 데이트", - icon: showHeart ? .heart(isSelected: true, onTap: nil) : .none, - cardWidth: 160, - imageSize: 160, - cornerRadius: 16, - iconPadding: 14, - iconSize: 20, - onCardTap: nil - ) + if viewModel.isLoading && viewModel.favoriteCoordinates.isEmpty { + ProgressView() + .padding(.top, 50) + } else { + LazyVGrid(columns: columns, spacing: 32) { + ForEach(viewModel.favoriteCoordinates, id: \.coordinateId) { coordinate in + CodiCard( + imageURL: URL(string: coordinate.imageUrl), + title: coordinate.coordinateName, + icon: .heart(isSelected: true, onTap: {}), + cardWidth: 160, + imageSize: 160, + cornerRadius: 16, + iconPadding: 14, + iconSize: 20, + onCardTap: {} + ) + } } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 24) } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 24) } } .background(Color.white) .navigationBarHidden(true) + .task { + await viewModel.loadFavoriteCoordinates() + } } } - -#Preview { - FavoriteCodiView(showHeart: true, navigationRouter: NavigationRouter()) -} diff --git a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift new file mode 100644 index 00000000..3734eecd --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift @@ -0,0 +1,37 @@ +// +// FavoriteCodiViewModel.swift +// Codive +// +// Created by 한금준 on 2/5/26. +// + +import Foundation + +@MainActor +final class FavoriteCodiViewModel: ObservableObject { + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var favoriteCoordinates: [MyFavoriteLookBookResponseDTO] = [] + + private let navigationRouter: NavigationRouter + private let fetchMyFavoriteLookBookUseCase: FetchMyFavoriteLookBookUseCase + + init(navigationRouter: NavigationRouter, fetchMyFavoriteLookBookUseCase: FetchMyFavoriteLookBookUseCase) { + self.navigationRouter = navigationRouter + self.fetchMyFavoriteLookBookUseCase = fetchMyFavoriteLookBookUseCase + } + + func loadFavoriteCoordinates() async { + isLoading = true + errorMessage = nil + + do { + self.favoriteCoordinates = try await fetchMyFavoriteLookBookUseCase.fetchMyFavoriteCoordinate() + } catch { + self.errorMessage = "데이터를 불러오는 데 실패했습니다." + print("Error fetching favorites: \(error)") + } + + isLoading = false + } +} diff --git a/Codive/Features/Search/Data/DTOs/SearchDTO.swift b/Codive/Features/Search/Data/DTOs/SearchDTO.swift index 0fe0401a..87d8471b 100644 --- a/Codive/Features/Search/Data/DTOs/SearchDTO.swift +++ b/Codive/Features/Search/Data/DTOs/SearchDTO.swift @@ -30,7 +30,7 @@ struct SearchRecommendationResponseDTO { /// 유저 검색 struct SearchUserResponseDTO { - let content : [SearchUserResponseItem] + let content: [SearchUserResponseItem] let isLast: Bool } @@ -60,7 +60,6 @@ struct SearchHistoryResponseItem { let profileImageUrl: String let nickname: String - func toEntity() -> SearchHistoriesEntity { return SearchHistoriesEntity( historyId: historyId, diff --git a/Codive/Features/Search/Data/Services/SearchAPIService.swift b/Codive/Features/Search/Data/Services/SearchAPIService.swift index ee8f5262..733290e4 100644 --- a/Codive/Features/Search/Data/Services/SearchAPIService.swift +++ b/Codive/Features/Search/Data/Services/SearchAPIService.swift @@ -166,7 +166,6 @@ extension SearchAPIService { } } - // MARK: - Search API Error enum SearchAPIError: LocalizedError { diff --git a/Codive/Features/Search/Presentation/Component/RecentlySearchResultRow.swift b/Codive/Features/Search/Presentation/Component/RecentlySearchResultRow.swift index e51113a9..d06222d6 100644 --- a/Codive/Features/Search/Presentation/Component/RecentlySearchResultRow.swift +++ b/Codive/Features/Search/Presentation/Component/RecentlySearchResultRow.swift @@ -86,24 +86,3 @@ struct RecentlySearchResultRow: View { .padding(.horizontal, 16) } } - -// 프리뷰 -#Preview { - VStack { - RecentlySearchResultRow( - type: .hashTag(title: "드뮤어룩"), - onDelete: { print("해시태그 삭제 클릭") } - ) - - Divider() - - RecentlySearchResultRow( - type: .member( - imageUrl: "https://example.com/profile.jpg", - title: "피크닉좋아", - subtitle: "hamster12" - ), - onDelete: { print("Delete clicked") } - ) - } -} diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index c82ad566..82869301 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -34,7 +34,7 @@ enum AppDestination: Hashable, Identifiable { case notification case lookbook case specificLookbook(lookbookId: Int64, name: String) - case addCodi(coordinateId: Int64) + case addCodi(lookBookId: Int64) case editCodi(selectedCodiData: SelectedCodi) case addCodiDetail case addBeforeCodi(lookbookId: Int64) diff --git a/Codive/Router/ViewFactory/LookBookViewFactory.swift b/Codive/Router/ViewFactory/LookBookViewFactory.swift index aa6e4318..447e0750 100644 --- a/Codive/Router/ViewFactory/LookBookViewFactory.swift +++ b/Codive/Router/ViewFactory/LookBookViewFactory.swift @@ -27,8 +27,8 @@ final class LookBookViewFactory { lookbookId: lookbookId, name: name ) - case .addCodi(let coordinateId): - lookBookDIContainer?.makeAddCodiView(coordinateId: coordinateId) + case .addCodi(let lookBookId): + lookBookDIContainer?.makeAddCodiView(lookBookId: lookBookId) case .addCodiDetail: lookBookDIContainer?.makeAddCodiDetailView() case .addBeforeCodi(let coordinateId): diff --git a/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift b/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift index 8c260d6b..3763e90c 100644 --- a/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift +++ b/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift @@ -21,7 +21,7 @@ enum MenuType { return [ .init(icon: .system(name: "pencil"), text: "코디 수정"), .init(icon: .system(name: "plus"), text: "룩북에 추가"), - .init(icon: .asset(name: "ic_share"), text: "코디 공유") + .init(icon: .asset(name: "ic_share"), text: "코디 저장") ] case .lookbook: return [ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 63e68730..c942eee4 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "171b3eb47b30ac0aac6bd97bc403c9602137b441" + "revision" : "25f2005a2950d0d541f29c9e3f4fba14e2ede945" } }, {