From d8cc2153ff930261cda2169f52e97429c67423ca Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 14:39:39 +0900 Subject: [PATCH 01/35] =?UTF-8?q?[#43]=20UseCase=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/DIContainer/HomeDIContainer.swift | 53 +++++++++++++++---- .../Domain/UseCases/CategoryUseCase.swift | 23 ++++++++ .../Domain/UseCases/CodiBoardUseCase.swift | 23 ++++++++ .../Home/Domain/UseCases/DateUseCase.swift | 19 +++++++ .../Domain/UseCases/FetchWeatherUseCase.swift | 21 ++++++++ .../Home/Domain/UseCases/HomeUseCase.swift | 51 ------------------ .../Domain/UseCases/TodayCodiUseCase.swift | 19 +++++++ .../ViewModel/CodiBoardViewModel.swift | 13 +++-- .../ViewModel/HomeViewModel.swift | 23 +++++--- 9 files changed, 174 insertions(+), 71 deletions(-) create mode 100644 Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift create mode 100644 Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift create mode 100644 Codive/Features/Home/Domain/UseCases/DateUseCase.swift create mode 100644 Codive/Features/Home/Domain/UseCases/FetchWeatherUseCase.swift delete mode 100644 Codive/Features/Home/Domain/UseCases/HomeUseCase.swift create mode 100644 Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift diff --git a/Codive/DIContainer/HomeDIContainer.swift b/Codive/DIContainer/HomeDIContainer.swift index 5be1489d..42a91373 100644 --- a/Codive/DIContainer/HomeDIContainer.swift +++ b/Codive/DIContainer/HomeDIContainer.swift @@ -21,38 +21,73 @@ final class HomeDIContainer { let locationService: LocationService = SystemLocationService() - lazy var homeDatasource = HomeDatasource(locationService: locationService) + // MARK: - DataSources + private lazy var homeDatasource: HomeDatasource = { + HomeDatasource(locationService: locationService) + }() - lazy var homeRepository: HomeRepository = HomeRepositoryImpl( - dataSource: homeDatasource - ) + // MARK: - Repositories + private lazy var homeRepository: HomeRepository = { + HomeRepositoryImpl(dataSource: homeDatasource) + }() - lazy var homeUseCase = HomeUseCase(repository: homeRepository) + // MARK: - UseCases (각 기능별) + + func makeFetchWeatherUseCase() -> FetchWeatherUseCase { + FetchWeatherUseCase(repository: homeRepository) + } + + func makeCategoryUseCase() -> CategoryUseCase { + CategoryUseCase(repository: homeRepository) + } + + func makeCodiBoardUseCase() -> CodiBoardUseCase { + CodiBoardUseCase(repository: homeRepository) + } + + func makeTodayCodiUseCase() -> TodayCodiUseCase { + TodayCodiUseCase(repository: homeRepository) + } + + func makeDateUseCase() -> DateUseCase { + DateUseCase(repository: homeRepository) + } // MARK: - Initializer init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter } + // MARK: - ViewModels + func makeHomeViewModel() -> HomeViewModel { return HomeViewModel( navigationRouter: navigationRouter, - useCase: homeUseCase + fetchWeatherUseCase: makeFetchWeatherUseCase(), + todayCodiUseCase: makeTodayCodiUseCase(), + dateUseCase: makeDateUseCase() ) } func makeEditCategoryViewModel() -> EditCategoryViewModel { - return EditCategoryViewModel(navigationRouter: navigationRouter) + return EditCategoryViewModel( + navigationRouter: navigationRouter + ) } func makeCodiBoardViewModel() -> CodiBoardViewModel { return CodiBoardViewModel( - navigationRouter: navigationRouter, useCase: homeUseCase + navigationRouter: navigationRouter, + codiBoardUseCase: makeCodiBoardUseCase() ) } + // MARK: - Views + func makeEditCategoryView() -> EditCategoryView { - return EditCategoryView(viewModel: makeEditCategoryViewModel()) + return EditCategoryView( + viewModel: makeEditCategoryViewModel() + ) } func makeCodiBoardView() -> CodiBoardView { diff --git a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift new file mode 100644 index 00000000..8bd08283 --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift @@ -0,0 +1,23 @@ +// +// CategoryUseCase.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +final class CategoryUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func loadCategories() -> [CategoryEntity] { + return repository.fetchCategories() + } + + func updateCategories(_ categories: [CategoryEntity]) { + repository.saveCategories(categories) + } +} diff --git a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift new file mode 100644 index 00000000..87bb994b --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift @@ -0,0 +1,23 @@ +// +// CodiBoardUseCase.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +final class CodiBoardUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func loadCodiBoardImages() -> [DraggableImageEntity] { + return repository.fetchInitialImages() + } + + func saveCodiItems(_ images: [DraggableImageEntity]) { + repository.saveCodiItems(images) + } +} diff --git a/Codive/Features/Home/Domain/UseCases/DateUseCase.swift b/Codive/Features/Home/Domain/UseCases/DateUseCase.swift new file mode 100644 index 00000000..aef3ce3d --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/DateUseCase.swift @@ -0,0 +1,19 @@ +// +// DateUseCase.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +final class DateUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func getToday() -> DateEntity { + return repository.getToday() + } +} diff --git a/Codive/Features/Home/Domain/UseCases/FetchWeatherUseCase.swift b/Codive/Features/Home/Domain/UseCases/FetchWeatherUseCase.swift new file mode 100644 index 00000000..37750cf6 --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/FetchWeatherUseCase.swift @@ -0,0 +1,21 @@ +// +// FetchWeatherUseCase.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +import CoreLocation + +final class FetchWeatherUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func execute(for location: CLLocation?) async throws -> WeatherData { + return try await repository.fetchWeatherData(for: location) + } +} diff --git a/Codive/Features/Home/Domain/UseCases/HomeUseCase.swift b/Codive/Features/Home/Domain/UseCases/HomeUseCase.swift deleted file mode 100644 index 163ba077..00000000 --- a/Codive/Features/Home/Domain/UseCases/HomeUseCase.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// HomeUseCase.swift -// Codive -// -// Created by 한금준 on 11/7/25. -// - -import CoreLocation - -final class HomeUseCase { - // MARK: - Properties - private let repository: HomeRepository - - // MARK: - Initializer - init(repository: HomeRepository) { - self.repository = repository - } - - // MARK: - Weather - func execute(for location: CLLocation?) async throws -> WeatherData { - return try await repository.fetchWeatherData(for: location) - } - - // MARK: - Categories - func loadCategories() -> [CategoryEntity] { - return repository.fetchCategories() - } - - func updateCategories(_ categories: [CategoryEntity]) { - repository.saveCategories(categories) - } - - // MARK: - Codi Items - func loadCodiBoardImages() -> [DraggableImageEntity] { - repository.fetchInitialImages() - } - - func saveCodiItems(_ images: [DraggableImageEntity]) { - repository.saveCodiItems(images) - } - - // MARK: - Today Codi - func loadTodaysCodi() -> [CodiItemEntity] { - repository.fetchCodiItems() - } - - // MARK: - Date - func getToday() -> DateEntity { - repository.getToday() - } -} diff --git a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift new file mode 100644 index 00000000..11b3154f --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift @@ -0,0 +1,19 @@ +// +// TodayCodiUseCase.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +final class TodayCodiUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func loadTodaysCodi() -> [CodiItemEntity] { + return repository.fetchCodiItems() + } +} diff --git a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift index 7683ac9a..d6e0639c 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -15,19 +15,22 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco @Published var images: [DraggableImageEntity] = [] @Published var currentlyDraggedID: Int? - private let useCase: HomeUseCase + private let codiBoardUseCase: CodiBoardUseCase private let navigationRouter: NavigationRouter // MARK: - Initializer - init(navigationRouter: NavigationRouter, useCase: HomeUseCase) { + init( + navigationRouter: NavigationRouter, + codiBoardUseCase: CodiBoardUseCase + ) { self.navigationRouter = navigationRouter - self.useCase = useCase + self.codiBoardUseCase = codiBoardUseCase loadInitialData() } // MARK: - Data Loading private func loadInitialData() { - images = useCase.loadCodiBoardImages() + images = codiBoardUseCase.loadCodiBoardImages() } // MARK: - Navigation @@ -37,7 +40,7 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco // MARK: - Actions func handleConfirmCodi() { - useCase.saveCodiItems(images) + codiBoardUseCase.saveCodiItems(images) isConfirmed = true } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index b8739775..03b63dba 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -28,12 +28,23 @@ final class HomeViewModel: ObservableObject { @AppStorage("SavedCategories") private var savedCategoriesData: Data? let navigationRouter: NavigationRouter - private let useCase: HomeUseCase + + // MARK: - UseCases + private let fetchWeatherUseCase: FetchWeatherUseCase + private let todayCodiUseCase: TodayCodiUseCase + private let dateUseCase: DateUseCase // MARK: - Initializer - init(navigationRouter: NavigationRouter, useCase: HomeUseCase) { + init( + navigationRouter: NavigationRouter, + fetchWeatherUseCase: FetchWeatherUseCase, + todayCodiUseCase: TodayCodiUseCase, + dateUseCase: DateUseCase + ) { self.navigationRouter = navigationRouter - self.useCase = useCase + self.fetchWeatherUseCase = fetchWeatherUseCase + self.todayCodiUseCase = todayCodiUseCase + self.dateUseCase = dateUseCase loadDummyCodi() loadToday() @@ -43,7 +54,7 @@ final class HomeViewModel: ObservableObject { // MARK: - Data Loading func loadWeather(for location: CLLocation?) async { do { - let data = try await useCase.execute(for: location) + let data = try await fetchWeatherUseCase.execute(for: location) weatherData = data } catch { print("Failed to fetch weather:", error) @@ -74,11 +85,11 @@ final class HomeViewModel: ObservableObject { } func loadDummyCodi() { - codiItems = useCase.loadTodaysCodi() + codiItems = todayCodiUseCase.loadTodaysCodi() } func loadToday() { - let entity = useCase.getToday() + let entity = dateUseCase.getToday() self.todayString = entity.formattedDate } From ef65e0803f01255582dea9a5df8d52d7b45edc81 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 20:34:41 +0900 Subject: [PATCH 02/35] =?UTF-8?q?[#43]=20CodiClothView=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Core/Resources/TextLiteral.swift | 2 + .../Component/CodiClothView.swift | 280 ++++++++++++------ .../Presentation/View/HomeNoCodiView.swift | 8 +- .../Home/Presentation/View/HomeView.swift | 1 + .../ViewModel/HomeViewModel.swift | 2 +- 5 files changed, 201 insertions(+), 92 deletions(-) diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index 93238b90..e841dcf5 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -138,6 +138,8 @@ enum TextLiteral { static let changeAlertTitle = "변경사항이 있습니다" static let changeAlertMessage = "변경사항을 저장하지 않고 나가시겠습니까?" static let leave = "나가기" + static let noClothTitle = "아직 옷이 없어요!" + static let noClothDescription = "옷을 추가해 날씨에\n맞게 코디 해봐요." } enum Search { diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index 73ff8fb2..b91f280f 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -7,11 +7,14 @@ import SwiftUI +// MARK: - Model + struct ClothItem: Identifiable { let id = UUID() - let color: Color } +// MARK: - Card (단일 슬롯) + struct ClothCardView: View { let item: ClothItem let width: CGFloat @@ -19,9 +22,15 @@ struct ClothCardView: View { var body: some View { VStack { ZStack(alignment: .topLeading) { - // 이미지 자리 RoundedRectangle(cornerRadius: 15) - .fill(Color.Codive.main0) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke( + Color.Codive.main4, + style: StrokeStyle(lineWidth: 1, dash: [6]) + ) + ) .frame(height: 124) .frame(width: 124) } @@ -30,110 +39,203 @@ struct ClothCardView: View { } } -struct CodiClothView: View { - let title: String - - // 예시 데이터 - let items: [ClothItem] = [ - ClothItem(color: Color(red: 0.65, green: 0.55, blue: 0.45)), - ClothItem(color: .red), - ClothItem(color: .blue), - ClothItem(color: .green), - ClothItem(color: .purple), - ClothItem(color: .orange) - ] +// MARK: - Carousel + +struct CodiClothCarouselView: View { + let items: [ClothItem] + @Binding var currentIndex: Int + let spacing: CGFloat + let activeScale: CGFloat + let inactiveScale: CGFloat + let isEmptyState: Bool - let spacing: CGFloat = 10 + // 빈 상태에서 사용하는 3개의 카드 구성 + @ViewBuilder + private func emptyStateCard(at index: Int, width: CGFloat) -> some View { + let border = RoundedRectangle(cornerRadius: 15) + .stroke( + Color.Codive.main4, + style: StrokeStyle(lineWidth: 1, dash: [6]) + ) + + switch index { + case 1: + // 가운데 카드: 텍스트 + 플러스 아이콘 + ZStack { + RoundedRectangle(cornerRadius: 15) + .fill(Color.white) + .overlay(border) + .frame(height: 124) + .frame(width: 124) + + VStack(spacing: 10) { + Text(TextLiteral.Home.noClothTitle) + .font(.codive_body2_medium) + .foregroundColor(Color.Codive.grayscale1) + + Text(TextLiteral.Home.noClothDescription) + .font(.codive_body3_regular) + .foregroundColor(Color.Codive.grayscale3) + .padding(.top, 4) + + Image("plus") + .resizable() + .scaledToFit() + .frame(width: 24) + } + } + .frame(width: width) + + default: + // 양옆 카드: 투명 배경 + 점선 테두리만 + ZStack { + RoundedRectangle(cornerRadius: 15) + .fill(Color.clear) + .overlay(border) + .frame(height: 124) + .frame(width: 124) + } + .frame(width: width) + } + } - @State private var currentIndex: Int - - init(title: String) { - self.title = title - _currentIndex = State(initialValue: items.count / 2) + @ViewBuilder + private func card(at index: Int, width: CGFloat) -> some View { + if isEmptyState { + emptyStateCard(at: index, width: width) + } else { + ClothCardView(item: items[index], width: width) + } } - // 스케일 설정 - let activeScale: CGFloat = 1.0 - let inactiveScale: CGFloat = 0.9 + // MARK: Body var body: some View { - ZStack(alignment: .topLeading) { - GeometryReader { geometry in - - let screenWidth = geometry.size.width - let itemWidth: CGFloat = screenWidth * 0.4 - let horizontalPadding: CGFloat = (screenWidth - itemWidth) / 2 - - ScrollViewReader { proxy in - - ScrollView(.horizontal, showsIndicators: false) { - - LazyHStack(spacing: spacing) { - - ForEach(items.indices, id: \.self) { index in - - ClothCardView(item: items[index], width: itemWidth) - .id(index) - .scaleEffect(index == currentIndex ? activeScale : inactiveScale) - .animation(.spring(), value: currentIndex) - } + GeometryReader { geometry in + let screenWidth = geometry.size.width + let itemWidth: CGFloat = screenWidth * 0.4 + let horizontalPadding: CGFloat = (screenWidth - itemWidth) / 2 + let totalCount = isEmptyState ? 3 : items.count + + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: spacing) { + ForEach(0.. itemWidth * dragThresholdRatio || abs(predictedOffset) > predictedEndOffsetThreshold { - if offset > 0 { - newIndex = max(0, currentIndex - 1) - } else { - newIndex = min(items.count - 1, currentIndex + 1) - } - } + } + .padding(.horizontal, horizontalPadding) + .gesture( + DragGesture() + .onEnded { value in + // 빈 상태에서는 드래그/스크롤 동작 X + if isEmptyState { return } + + let offset = value.translation.width + let predictedOffset = value.predictedEndTranslation.width + + var newIndex = currentIndex + + let dragThresholdRatio: CGFloat = 1.0 / 3.0 + let predictedEndOffsetThreshold: CGFloat = 100 + + if abs(offset) > itemWidth * dragThresholdRatio || + abs(predictedOffset) > predictedEndOffsetThreshold { - withAnimation(.spring()) { - proxy.scrollTo(newIndex, anchor: .center) - currentIndex = newIndex + if offset > 0 { + newIndex = max(0, currentIndex - 1) + } else { + newIndex = min(totalCount - 1, currentIndex + 1) } } - ) - } - .onAppear { - proxy.scrollTo(currentIndex, anchor: .center) - } + + withAnimation(.spring()) { + proxy.scrollTo(newIndex, anchor: .center) + currentIndex = newIndex + } + } + ) } - .clipShape(RoundedRectangle(cornerRadius: 20)) - .background(alignment: .center) { - Color.Codive.grayscale7 + .scrollDisabled(isEmptyState) + .onAppear { + proxy.scrollTo(currentIndex, anchor: .center) } } - .frame(height: 148) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .background { + Color.Codive.grayscale7 + } + } + .frame(height: 148) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - Entry View + +struct CodiClothView: View { + let title: String + let items: [ClothItem] + let isEmptyState: Bool + + private let spacing: CGFloat = 10 + private let activeScale: CGFloat = 1.0 + private let inactiveScale: CGFloat = 0.9 + + @State private var currentIndex: Int + + init(title: String, + items: [ClothItem] = [], + isEmptyState: Bool) { + + self.title = title + self.items = items + self.isEmptyState = isEmptyState + + let initialIndex = isEmptyState ? 1 : max(0, items.count / 2) + _currentIndex = State(initialValue: initialIndex) + } + + var body: some View { + HStack(spacing: 8) { + ZStack(alignment: .topLeading) { + CodiClothCarouselView( + items: items, + currentIndex: $currentIndex, + spacing: spacing, + activeScale: activeScale, + inactiveScale: inactiveScale, + isEmptyState: isEmptyState + ) + + Text(title) + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + Color.Codive.main3 + } + .clipShape(Capsule()) + .padding(.horizontal, 10) + .padding(.vertical, 10) + } - Text(title) - .font(.caption.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(alignment: .center) { - Color.Codive.main3 - } - .clipShape(Capsule()) - .padding(.horizontal, 10) - .padding(.vertical, 10) + Image("move") + .resizable() + .scaledToFit() + .frame(width: 11) } } } +// MARK: - Preview + #Preview { - CodiClothView(title: "바지") + // 빈 상태 미리보기 (3칸 + 안내 카드) + CodiClothView(title: "바지", items: [], isEmptyState: true) .padding(.horizontal, 20) } diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index 918639e2..edb2b620 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -46,7 +46,11 @@ struct HomeNoCodiView: View { private var codiClothList: some View { VStack(spacing: 16) { ForEach(viewModel.activeCategories) { category in - CodiClothView(title: category.title) + CodiClothView( + title: category.title, + items: [], // 아직 실제 ClothItem 배열이 없으니까 빈 배열 + isEmptyState: category.itemCount == 0 // 외부에서 비어있는지 여부를 넘겨줌 + ) } } .padding(.horizontal, 20) @@ -58,7 +62,7 @@ struct HomeNoCodiView: View { let totalWidth = geometry.size.width - 40 let availableWidth = totalWidth - 16 let button1Width = availableWidth / 3 - let button2Width = availableWidth * 2 / 3 + let button2Width = availableWidth * 2 / 3 HStack(spacing: 16) { Button(action: viewModel.handleCodiBoardTap) { diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index eae62aad..e8987e2f 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -51,6 +51,7 @@ struct HomeView: View { } } } + .padding(.bottom, 72) } .background(alignment: .center) { Color.white diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 03b63dba..580bf1ca 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -14,7 +14,7 @@ import CoreLocation final class HomeViewModel: ObservableObject { // MARK: - Properties - @Published var hasCodi: Bool = true + @Published var hasCodi: Bool = false @Published var selectedIndex: Int? = 0 @Published var showClothSelector: Bool = false @Published var titleFrame: CGRect = .zero From 5a054c65d7ef8c14fb7e8d5d7e64211c119b51b9 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 20:58:27 +0900 Subject: [PATCH 03/35] =?UTF-8?q?[#43]=20HomeNoCodiView=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/DIContainer/HomeDIContainer.swift | 3 +- .../Data/DataSources/HomeDatasource.swift | 27 ++++++++++++ .../Repositories/HomeRepositoryImpl.swift | 5 +++ .../Home/Domain/Entities/HomeEntity.swift | 7 ++++ .../Domain/Protocols/HomeRepository.swift | 3 ++ .../Domain/UseCases/CategoryUseCase.swift | 4 ++ .../Component/CodiClothView.swift | 42 +++++++++++++++---- .../Presentation/View/HomeNoCodiView.swift | 6 ++- .../ViewModel/HomeViewModel.swift | 32 +++++--------- 9 files changed, 96 insertions(+), 33 deletions(-) diff --git a/Codive/DIContainer/HomeDIContainer.swift b/Codive/DIContainer/HomeDIContainer.swift index 42a91373..bbd0b58b 100644 --- a/Codive/DIContainer/HomeDIContainer.swift +++ b/Codive/DIContainer/HomeDIContainer.swift @@ -65,7 +65,8 @@ final class HomeDIContainer { navigationRouter: navigationRouter, fetchWeatherUseCase: makeFetchWeatherUseCase(), todayCodiUseCase: makeTodayCodiUseCase(), - dateUseCase: makeDateUseCase() + dateUseCase: makeDateUseCase(), + categoryUseCase: makeCategoryUseCase() ) } diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 4e6b9cf0..94061e90 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -128,6 +128,33 @@ final class HomeDatasource { categories.forEach { print("\($0.id): \($0.title): \($0.itemCount)") } } + // MARK: - Cloth Items + func loadClothItems() -> [HomeClothEntity] { + // TODO: 서버 연동 시 실제 API 응답으로 교체 + return [ + HomeClothEntity( + id: 1, + categoryId: 1, + imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" + ), + HomeClothEntity( + id: 2, + categoryId: 1, + imageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" + ), + HomeClothEntity( + id: 3, + categoryId: 2, + imageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" + ), + HomeClothEntity( + id: 4, + categoryId: 5, + imageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" + ) + ] + } + // MARK: - Codi Items func loadInitialImages() -> [DraggableImageEntity] { return [ diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index fbfed271..e9abaea5 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -29,6 +29,11 @@ final class HomeRepositoryImpl: HomeRepository { func saveCategories(_ categories: [CategoryEntity]) { dataSource.saveCategories(categories) } + + // MARK: - Cloth Items + func fetchClothItems() -> [HomeClothEntity] { + dataSource.loadClothItems() + } // MARK: - Codi Items func fetchInitialImages() -> [DraggableImageEntity] { diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index aa212d4f..c77d3302 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -32,6 +32,13 @@ struct CategoryEntity: Identifiable, Codable { var itemCount: Int } +// MARK: - Home Cloth +struct HomeClothEntity: Identifiable, Codable { + let id: Int + let categoryId: Int + let imageUrl: String +} + // MARK: - Draggable Image struct DraggableImageEntity: Identifiable, Hashable { let id: Int diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 426f4f9e..2a0a7836 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -15,6 +15,9 @@ protocol HomeRepository { func fetchCategories() -> [CategoryEntity] func saveCategories(_ categories: [CategoryEntity]) + // MARK: - Cloth Items + func fetchClothItems() -> [HomeClothEntity] + // MARK: - Initial Images func fetchInitialImages() -> [DraggableImageEntity] diff --git a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift index 8bd08283..a9e77b8b 100644 --- a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift @@ -17,6 +17,10 @@ final class CategoryUseCase { return repository.fetchCategories() } + func loadClothItems() -> [HomeClothEntity] { + return repository.fetchClothItems() + } + func updateCategories(_ categories: [CategoryEntity]) { repository.saveCategories(categories) } diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index b91f280f..ddb42af8 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -7,16 +7,11 @@ import SwiftUI -// MARK: - Model - -struct ClothItem: Identifiable { - let id = UUID() -} // MARK: - Card (단일 슬롯) struct ClothCardView: View { - let item: ClothItem + let item: HomeClothEntity let width: CGFloat var body: some View { @@ -33,6 +28,35 @@ struct ClothCardView: View { ) .frame(height: 124) .frame(width: 124) + .overlay { + // 서버 URL 혹은 로컬 에셋 이름 모두 대응 가능하게 처리 + if let url = URL(string: item.imageUrl), item.imageUrl.hasPrefix("http") { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .scaledToFit() + case .failure: + Image(systemName: "photo") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + @unknown default: + EmptyView() + } + } + .clipShape(RoundedRectangle(cornerRadius: 15)) + } else { + // 로컬 에셋 이름으로 처리 + Image(item.imageUrl) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 15)) + } + } } .frame(width: width) } @@ -42,7 +66,7 @@ struct ClothCardView: View { // MARK: - Carousel struct CodiClothCarouselView: View { - let items: [ClothItem] + let items: [HomeClothEntity] @Binding var currentIndex: Int let spacing: CGFloat let activeScale: CGFloat @@ -178,7 +202,7 @@ struct CodiClothCarouselView: View { struct CodiClothView: View { let title: String - let items: [ClothItem] + let items: [HomeClothEntity] let isEmptyState: Bool private let spacing: CGFloat = 10 @@ -188,7 +212,7 @@ struct CodiClothView: View { @State private var currentIndex: Int init(title: String, - items: [ClothItem] = [], + items: [HomeClothEntity] = [], isEmptyState: Bool) { self.title = title diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index edb2b620..53cc5198 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -46,10 +46,12 @@ struct HomeNoCodiView: View { private var codiClothList: some View { VStack(spacing: 16) { ForEach(viewModel.activeCategories) { category in + let clothItems = viewModel.clothItemsByCategory[category.id] ?? [] + CodiClothView( title: category.title, - items: [], // 아직 실제 ClothItem 배열이 없으니까 빈 배열 - isEmptyState: category.itemCount == 0 // 외부에서 비어있는지 여부를 넘겨줌 + items: clothItems, + isEmptyState: clothItems.isEmpty ) } } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 580bf1ca..6f151c67 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -24,8 +24,7 @@ final class HomeViewModel: ObservableObject { @Published var selectedItemID: Int? @Published var codiItems: [CodiItemEntity] = [] @Published var activeCategories: [CategoryEntity] = [] - - @AppStorage("SavedCategories") private var savedCategoriesData: Data? + @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] let navigationRouter: NavigationRouter @@ -33,18 +32,21 @@ final class HomeViewModel: ObservableObject { private let fetchWeatherUseCase: FetchWeatherUseCase private let todayCodiUseCase: TodayCodiUseCase private let dateUseCase: DateUseCase + private let categoryUseCase: CategoryUseCase // MARK: - Initializer init( navigationRouter: NavigationRouter, fetchWeatherUseCase: FetchWeatherUseCase, todayCodiUseCase: TodayCodiUseCase, - dateUseCase: DateUseCase + dateUseCase: DateUseCase, + categoryUseCase: CategoryUseCase ) { self.navigationRouter = navigationRouter self.fetchWeatherUseCase = fetchWeatherUseCase self.todayCodiUseCase = todayCodiUseCase self.dateUseCase = dateUseCase + self.categoryUseCase = categoryUseCase loadDummyCodi() loadToday() @@ -63,25 +65,13 @@ final class HomeViewModel: ObservableObject { } func loadActiveCategories() { - let allCategories: [CategoryEntity] - if let data = savedCategoriesData, - let decoded = try? JSONDecoder().decode([CategoryEntity].self, from: data) { - allCategories = decoded - } else { - allCategories = [ - CategoryEntity(id: 1, title: "상의", itemCount: 1), - CategoryEntity(id: 2, title: "바지", itemCount: 1), - CategoryEntity(id: 3, title: "스커트", itemCount: 0), - CategoryEntity(id: 4, title: "아우터", itemCount: 0), - CategoryEntity(id: 5, title: "신발", itemCount: 1), - CategoryEntity(id: 6, title: "가방", itemCount: 0), - CategoryEntity(id: 7, title: "패션 소품", itemCount: 0) - ] - } + // 카테고리: 저장된 값 또는 기본값을 CategoryUseCase를 통해 로딩 + let categories = categoryUseCase.loadCategories() + activeCategories = categories - activeCategories = allCategories.flatMap { category in - Array(repeating: category, count: category.itemCount) - } + // 옷 데이터: 카테고리별로 그룹화 + let clothItems = categoryUseCase.loadClothItems() + clothItemsByCategory = Dictionary(grouping: clothItems, by: { $0.categoryId }) } func loadDummyCodi() { From abd16e8ac3d89bd6b36cafddaa6314f0c5730119 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 21:01:33 +0900 Subject: [PATCH 04/35] =?UTF-8?q?[#43]=20CategoryView=20TextLiteral=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Core/Resources/TextLiteral.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index e841dcf5..48f72a7f 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -135,8 +135,8 @@ enum TextLiteral { static let failWeather = "날씨 정보를 가져오는 데 실패했습니다." static let todayCodiTitle = "오늘의 코디" static let currentCategoryCount = "현재 카테고리" - static let changeAlertTitle = "변경사항이 있습니다" - static let changeAlertMessage = "변경사항을 저장하지 않고 나가시겠습니까?" + static let changeAlertTitle = "정말 나가시겠습니까?" + static let changeAlertMessage = "작성중인 내용은 복구할 수 없습니다" static let leave = "나가기" static let noClothTitle = "아직 옷이 없어요!" static let noClothDescription = "옷을 추가해 날씨에\n맞게 코디 해봐요." From 21095bffb74a0369f2d1d5f88612d61d56c08474 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 21:03:27 +0900 Subject: [PATCH 05/35] =?UTF-8?q?[#43]=20=EA=B2=BD=EA=B3=A0=EB=AC=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Features/Home/Presentation/Component/CodiClothView.swift | 1 - Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index ddb42af8..a44b5cd0 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -7,7 +7,6 @@ import SwiftUI - // MARK: - Card (단일 슬롯) struct ClothCardView: View { diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 6f151c67..3f63e06a 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -71,7 +71,7 @@ final class HomeViewModel: ObservableObject { // 옷 데이터: 카테고리별로 그룹화 let clothItems = categoryUseCase.loadClothItems() - clothItemsByCategory = Dictionary(grouping: clothItems, by: { $0.categoryId }) + clothItemsByCategory = Dictionary(grouping: clothItems) { $0.categoryId } } func loadDummyCodi() { From e653c38ad8da6f67faaa56e238ffd25c816d6e23 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 21:25:33 +0900 Subject: [PATCH 06/35] =?UTF-8?q?[#43]=20CodiBoardView=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 19 ++++++++++------- .../Repositories/HomeRepositoryImpl.swift | 4 ++-- .../Home/Domain/Entities/HomeEntity.swift | 17 ++++++++++++++- .../Domain/Protocols/HomeRepository.swift | 2 +- .../Domain/UseCases/CodiBoardUseCase.swift | 21 ++++++++++++++++++- 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 94061e90..6b5e924a 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -167,14 +167,19 @@ final class HomeDatasource { ] } - func saveCodiItems(_ images: [DraggableImageEntity]) { - print("코디 저장 완료 (\(images.count)개)") - for image in images { - let pos = "pos: (\(Int(image.position.x)), \(Int(image.position.y)))" - let scaleStr = "scale: \(String(format: "%.2f", image.scale))" - let rotStr = "rotation: \(String(format: "%.2f", image.rotationAngle))°" - print("• \(image.name) →", pos + ",", scaleStr + ",", rotStr) + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) { + print("===== 📦 Codi Coordinate Request Mock =====") + print("coordinateImageUrl:", request.coordinateImageUrl) + print("Payload count:", request.Payload.count) + + for (index, item) in request.Payload.enumerated() { + let pos = String(format: "(%.1f, %.1f)", item.locationX, item.locationY) + let ratioStr = String(format: "%.2f", item.ratio) + let degreeStr = String(format: "%.2f", item.degree) + print("[\(index)] clothId: \(item.clothId), pos: \(pos), ratio: \(ratioStr), degree: \(degreeStr), order: \(item.order)") } + + print("===== ✅ Mock request complete =====") } func loadDummyCodiItems() -> [CodiItemEntity] { diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index e9abaea5..5e59de86 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -40,8 +40,8 @@ final class HomeRepositoryImpl: HomeRepository { dataSource.loadInitialImages() } - func saveCodiItems(_ images: [DraggableImageEntity]) { - dataSource.saveCodiItems(images) + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) { + dataSource.saveCodiCoordinate(request) } func fetchCodiItems() -> [CodiItemEntity] { diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index c77d3302..751c5334 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -45,7 +45,22 @@ struct DraggableImageEntity: Identifiable, Hashable { let name: String var position: CGPoint var scale: CGFloat - var rotationAngle: Double + var rotationAngle: Double +} + +// MARK: - Codi Coordinate Request (for server) +struct CodiCoordinateRequestDTO: Codable { + let coordinateImageUrl: String + let Payload: [CodiCoordinatePayloadDTO] +} + +struct CodiCoordinatePayloadDTO: Codable { + let clothId: Int64 + let locationX: Double + let locationY: Double + let ratio: Double + let degree: Double + let order: Int } // MARK: - Codi Item diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 2a0a7836..b4d5c1ce 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -22,7 +22,7 @@ protocol HomeRepository { func fetchInitialImages() -> [DraggableImageEntity] // MARK: - Codi Items - func saveCodiItems(_ images: [DraggableImageEntity]) + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) func fetchCodiItems() -> [CodiItemEntity] // MARK: - Date diff --git a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift index 87bb994b..f9c6d061 100644 --- a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift @@ -18,6 +18,25 @@ final class CodiBoardUseCase { } func saveCodiItems(_ images: [DraggableImageEntity]) { - repository.saveCodiItems(images) + // TODO: 실제 스냅샷 URL을 전달받도록 변경 예정 (현재는 mock URL 사용) + let mockCoordinateImageUrl = "https://example.com/mock-today-codi-snapshot.jpg" + + let payloads: [CodiCoordinatePayloadDTO] = images.enumerated().map { index, image in + CodiCoordinatePayloadDTO( + clothId: Int64(image.id), // 실제 clothId 필드가 생기면 교체 + locationX: Double(image.position.x), + locationY: Double(image.position.y), + ratio: Double(image.scale), + degree: image.rotationAngle, + order: index + ) + } + + let request = CodiCoordinateRequestDTO( + coordinateImageUrl: mockCoordinateImageUrl, + Payload: payloads + ) + + repository.saveCodiCoordinate(request) } } From 09d756cd605f259acde30dfae46cf2c534b7d933 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 25 Dec 2025 22:34:49 +0900 Subject: [PATCH 07/35] =?UTF-8?q?[#43]=20=EC=BD=94=EB=94=94=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=ED=8C=9D=EC=97=85=EC=B0=BD=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Core/Resources/TextLiteral.swift | 4 + .../Component/CompletePopUp.swift | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 Codive/Features/Home/Presentation/Component/CompletePopUp.swift diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index 48f72a7f..289c4be5 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -140,6 +140,10 @@ enum TextLiteral { static let leave = "나가기" static let noClothTitle = "아직 옷이 없어요!" static let noClothDescription = "옷을 추가해 날씨에\n맞게 코디 해봐요." + static let popUpTitle = "오늘의 코디 완성!" + static let popUpSubtitle = "오늘의 코디는 홈 화면에서 하루 동안만 유지\n됩니다. 추가로 기록하려면 피드에 남겨보세요!" + static let close = "닫기" + static let record = "기록하기" } enum Search { diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift new file mode 100644 index 00000000..ae74c2f8 --- /dev/null +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -0,0 +1,116 @@ +// +// CompletePopUp.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +import SwiftUI + +struct CompletePopUp: View { + /// 팝업 표시 여부를 제어하는 바인딩 + @Binding var isPresented: Bool + + /// 기록하기 버튼 액션 + var onRecordTapped: () -> Void + /// 닫기 버튼 액션 + var onCloseTapped: () -> Void + /// 코디 이미지 URL + var imageURL: String? + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { + isPresented = false + } + + VStack { + Spacer(minLength: 32) + + popupCard + .padding(.horizontal, 32) + .aspectRatio(311 / 360, contentMode: .fit) + + Spacer(minLength: 32) + } + } + } + + private var popupCard: some View { + VStack(spacing: 6) { + Text(TextLiteral.Home.popUpTitle) + .font(.codive_title1) + .foregroundColor(Color.Codive.grayscale1) + .padding(.top, 32) + + Text(TextLiteral.Home.popUpSubtitle) + .font(.codive_body2_regular) + .foregroundColor(Color.Codive.grayscale3) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + AsyncImage(url: URL(string: imageURL ?? "")) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 204, height: 204) + + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: 204, height: 204) + .clipped() + + case .failure: + Image(systemName: "photo") + .resizable() + .scaledToFill() + .foregroundColor(.gray.opacity(0.4)) + .frame(width: 204, height: 204) + .clipped() + + @unknown default: + EmptyView() + } + } + .padding(.vertical, 16) + + HStack(spacing: 9) { + CustomButton( + text: TextLiteral.Home.close, + widthType: .half, + styleType: .border + ) { + isPresented = false + onCloseTapped() + } + + CustomButton( + text: TextLiteral.Home.record, + widthType: .half + ) { + onRecordTapped() + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + ) + } +} + +#Preview { + CompletePopUp( + isPresented: .constant(true), + onRecordTapped: {}, + onCloseTapped: {}, + imageURL: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" + ) +} From 28f46860595c44eaa375a9428827108331d890ed Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 26 Dec 2025 00:37:53 +0900 Subject: [PATCH 08/35] =?UTF-8?q?[#43]=20=EC=BD=94=EB=94=94=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=ED=9B=84=20=ED=8C=9D=EC=97=85=20=EB=9D=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/DIContainer/HomeDIContainer.swift | 15 +++- .../Home/Domain/Entities/HomeEntity.swift | 1 + .../Home/Presentation/View/HomeView.swift | 73 ++++++++++++------- .../ViewModel/CodiBoardViewModel.swift | 17 ++++- .../ViewModel/HomeViewModel.swift | 23 +++++- 5 files changed, 96 insertions(+), 33 deletions(-) diff --git a/Codive/DIContainer/HomeDIContainer.swift b/Codive/DIContainer/HomeDIContainer.swift index bbd0b58b..c4eb2246 100644 --- a/Codive/DIContainer/HomeDIContainer.swift +++ b/Codive/DIContainer/HomeDIContainer.swift @@ -21,6 +21,9 @@ final class HomeDIContainer { let locationService: LocationService = SystemLocationService() + // HomeViewModel 싱글톤 인스턴스 저장 + private var homeViewModel: HomeViewModel? + // MARK: - DataSources private lazy var homeDatasource: HomeDatasource = { HomeDatasource(locationService: locationService) @@ -61,13 +64,20 @@ final class HomeDIContainer { // MARK: - ViewModels func makeHomeViewModel() -> HomeViewModel { - return HomeViewModel( + if let existingViewModel = homeViewModel { + return existingViewModel + } + + let viewModel = HomeViewModel( navigationRouter: navigationRouter, fetchWeatherUseCase: makeFetchWeatherUseCase(), todayCodiUseCase: makeTodayCodiUseCase(), dateUseCase: makeDateUseCase(), categoryUseCase: makeCategoryUseCase() ) + + homeViewModel = viewModel + return viewModel } func makeEditCategoryViewModel() -> EditCategoryViewModel { @@ -79,7 +89,8 @@ final class HomeDIContainer { func makeCodiBoardViewModel() -> CodiBoardViewModel { return CodiBoardViewModel( navigationRouter: navigationRouter, - codiBoardUseCase: makeCodiBoardUseCase() + codiBoardUseCase: makeCodiBoardUseCase(), + homeViewModel: homeViewModel ) } diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 751c5334..b3a1a795 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -46,6 +46,7 @@ struct DraggableImageEntity: Identifiable, Hashable { var position: CGPoint var scale: CGFloat var rotationAngle: Double + let imageURL: String? = nil } // MARK: - Codi Coordinate Request (for server) diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index e8987e2f..d98955f1 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -12,6 +12,7 @@ struct HomeView: View { private let homeDIContainer: HomeDIContainer @StateObject private var viewModel: HomeViewModel @ObservedObject private var navigationRouter: NavigationRouter + @State private var scrollViewID = UUID() init(homeDIContainer: HomeDIContainer) { self.homeDIContainer = homeDIContainer @@ -21,40 +22,54 @@ struct HomeView: View { var body: some View { GeometryReader { outerGeometry in - VStack(spacing: 0) { - ScrollView { - VStack { - if let weather = viewModel.weatherData { - WeatherCardView(weatherData: weather) - .padding(.horizontal, 20) - .padding(.top, 16) - } else { - if let errorMessage = viewModel.weatherErrorMessage { - Text(errorMessage) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.top, 16) + ZStack { + VStack(spacing: 0) { + ScrollView { + VStack { + if let weather = viewModel.weatherData { + WeatherCardView(weatherData: weather) .padding(.horizontal, 20) - } else { - ProgressView(TextLiteral.Home.weatherLoading) .padding(.top, 16) + } else { + if let errorMessage = viewModel.weatherErrorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.top, 16) + .padding(.horizontal, 20) + } else { + ProgressView(TextLiteral.Home.weatherLoading) + .padding(.top, 16) + } + } + + if viewModel.hasCodi { + HomeHasCodiView( + viewModel: viewModel, + width: outerGeometry.size.width + ) + } else { + HomeNoCodiView(viewModel: viewModel) } - } - - if viewModel.hasCodi { - HomeHasCodiView( - viewModel: viewModel, - width: outerGeometry.size.width - ) - } else { - HomeNoCodiView(viewModel: viewModel) } } + .id(scrollViewID) // 스크롤 초기화를 위한 ID + .padding(.bottom, 72) + } + .background(alignment: .center) { + Color.white + } + + // 팝업 오버레이 + if viewModel.showCompletePopUp { + CompletePopUp( + isPresented: $viewModel.showCompletePopUp, + onRecordTapped: viewModel.handlePopupRecord, + onCloseTapped: viewModel.handlePopupClose, + imageURL: viewModel.completedCodiImageURL + ) + .zIndex(1) } - .padding(.bottom, 72) - } - .background(alignment: .center) { - Color.white } .task { await viewModel.loadWeather(for: nil) @@ -62,6 +77,8 @@ struct HomeView: View { .onChange(of: navigationRouter.currentDestination) { newDestination in if newDestination == nil { viewModel.loadActiveCategories() + // 홈으로 돌아올 때 스크롤 초기화 + scrollViewID = UUID() } } } diff --git a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift index d6e0639c..e30a598c 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -17,14 +17,17 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco private let codiBoardUseCase: CodiBoardUseCase private let navigationRouter: NavigationRouter + private weak var homeViewModel: HomeViewModel? // MARK: - Initializer init( navigationRouter: NavigationRouter, - codiBoardUseCase: CodiBoardUseCase + codiBoardUseCase: CodiBoardUseCase, + homeViewModel: HomeViewModel? = nil ) { self.navigationRouter = navigationRouter self.codiBoardUseCase = codiBoardUseCase + self.homeViewModel = homeViewModel loadInitialData() } @@ -41,6 +44,18 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco // MARK: - Actions func handleConfirmCodi() { codiBoardUseCase.saveCodiItems(images) + + // 코디 이미지 URL 생성 (실제로는 저장된 이미지의 URL을 가져와야 함) + let imageURL = images.first?.imageURL + + // 뒤로 이동 + navigationRouter.navigateBack() + + // 팝업 표시 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.homeViewModel?.showCompletionPopup(imageURL: imageURL) + } + isConfirmed = true } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 3f63e06a..d83b8e7f 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -26,6 +26,10 @@ final class HomeViewModel: ObservableObject { @Published var activeCategories: [CategoryEntity] = [] @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] + // 팝업 관련 프로퍼티 추가 + @Published var showCompletePopUp: Bool = false + @Published var completedCodiImageURL: String? + let navigationRouter: NavigationRouter // MARK: - UseCases @@ -65,11 +69,9 @@ final class HomeViewModel: ObservableObject { } func loadActiveCategories() { - // 카테고리: 저장된 값 또는 기본값을 CategoryUseCase를 통해 로딩 let categories = categoryUseCase.loadCategories() activeCategories = categories - // 옷 데이터: 카테고리별로 그룹화 let clothItems = categoryUseCase.loadClothItems() clothItemsByCategory = Dictionary(grouping: clothItems) { $0.categoryId } } @@ -114,6 +116,23 @@ final class HomeViewModel: ObservableObject { navigationRouter.navigate(to: .editCategory) } + // MARK: - Popup Actions + func showCompletionPopup(imageURL: String?) { + completedCodiImageURL = imageURL + showCompletePopUp = true + } + + func handlePopupRecord() { + showCompletePopUp = false + // 기록하기 로직 구현 + // 예: navigationRouter.navigate(to: .recordCodi) + } + + func handlePopupClose() { + showCompletePopUp = false + completedCodiImageURL = nil + } + // MARK: - Lifecycle func onAppear() { loadActiveCategories() From b257dac2bafa892aa9752d0eb721508cabae1aec Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 26 Dec 2025 00:59:28 +0900 Subject: [PATCH 09/35] =?UTF-8?q?[#43]=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=B3=84=20=EA=B3=84?= =?UTF-8?q?=EC=A0=88=20=EC=98=B7=20=EC=B6=94=EC=B2=9C=20api=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 42 ++++++++++-- .../Repositories/HomeRepositoryImpl.swift | 9 +++ .../Home/Domain/Entities/HomeEntity.swift | 49 ++++++++++++++ .../Domain/Protocols/HomeRepository.swift | 1 + .../Domain/UseCases/CategoryUseCase.swift | 17 +++++ .../Home/Presentation/View/HomeView.swift | 1 + .../ViewModel/HomeViewModel.swift | 65 +++++++++++++++++++ 7 files changed, 180 insertions(+), 4 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 6b5e924a..80206ae5 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -95,7 +95,6 @@ final class HomeDatasource { currentTemp: currentTemp, symbolName: symbolName, dailyForecasts: Array(dailyForecasts), - // MARK: - 수정: 위치 이름을 추가 locationName: locationName ) @@ -110,7 +109,7 @@ final class HomeDatasource { } return [ - CategoryEntity(id: 1, title: "상의", itemCount: 1), + CategoryEntity(id: 1, title: "상의", itemCount: 2), CategoryEntity(id: 2, title: "바지", itemCount: 1), CategoryEntity(id: 3, title: "스커트", itemCount: 0), CategoryEntity(id: 4, title: "아우터", itemCount: 0), @@ -128,9 +127,44 @@ final class HomeDatasource { categories.forEach { print("\($0.id): \($0.title): \($0.itemCount)") } } - // MARK: - Cloth Items + // MARK: - Cloth Items (API Mock) + func fetchClothItems(request: ClothListRequestDTO) async throws -> [ClothListResponseDTO] { + // TODO: 실제 API 호출로 교체 + // let response = try await apiClient.get("/api/clothes", parameters: request.toQueryParameters()) + + print("===== 🔵 Cloth List Request Mock =====") + print("Request Parameters:", request.toQueryParameters()) + + // Mock Response Data + await Task.sleep(500_000_000) // 0.5초 딜레이 (네트워크 시뮬레이션) + + let mockResponse: [ClothListResponseDTO] = [ + ClothListResponseDTO( + clothId: 1, + clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" + ), + ClothListResponseDTO( + clothId: 2, + clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" + ), + ClothListResponseDTO( + clothId: 3, + clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" + ), + ClothListResponseDTO( + clothId: 4, + clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" + ) + ] + + print("Response Count:", mockResponse.count) + print("===== ✅ Mock response complete =====") + + return mockResponse + } + func loadClothItems() -> [HomeClothEntity] { - // TODO: 서버 연동 시 실제 API 응답으로 교체 + // 기존 메서드는 유지 (하위 호환성) return [ HomeClothEntity( id: 1, diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index 5e59de86..9523a4f5 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -35,6 +35,15 @@ final class HomeRepositoryImpl: HomeRepository { dataSource.loadClothItems() } + // 새로운 API 기반 메서드 + func fetchClothItems(request: ClothListRequestDTO) async throws -> [HomeClothEntity] { + let dtoList = try await dataSource.fetchClothItems(request: request) + + // categoryId를 request에서 가져오거나 기본값 사용 + let categoryId = Int(request.categoryId ?? 1) + return dtoList.toEntities(categoryId: categoryId) + } + // MARK: - Codi Items func fetchInitialImages() -> [DraggableImageEntity] { dataSource.loadInitialImages() diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index b3a1a795..45864405 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -78,3 +78,52 @@ struct CodiItemEntity: Identifiable { struct DateEntity { let formattedDate: String } + +// MARK: - Cloth List Request DTO +struct ClothListRequestDTO { + let lastClothId: Int64? + let size: Int + let categoryId: Int64? + let season: String? + + func toQueryParameters() -> [String: String] { + var params: [String: String] = [:] + + if let lastClothId = lastClothId { + params["lastClothId"] = String(lastClothId) + } + params["size"] = String(size) + if let categoryId = categoryId { + params["categoryId"] = String(categoryId) + } + if let season = season { + params["season"] = season + } + + return params + } +} + +// MARK: - Cloth List Response DTO +struct ClothListResponseDTO: Codable { + let clothId: Int64 + let clothImageUrl: String +} + +// MARK: - DTO to Entity Mapping +extension ClothListResponseDTO { + func toEntity(categoryId: Int) -> HomeClothEntity { + return HomeClothEntity( + id: Int(clothId), + categoryId: categoryId, + imageUrl: clothImageUrl + ) + } +} + +// MARK: - Multiple Response Mapping +extension Array where Element == ClothListResponseDTO { + func toEntities(categoryId: Int) -> [HomeClothEntity] { + return self.map { $0.toEntity(categoryId: categoryId) } + } +} diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index b4d5c1ce..c81a058c 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -17,6 +17,7 @@ protocol HomeRepository { // MARK: - Cloth Items func fetchClothItems() -> [HomeClothEntity] + func fetchClothItems(request: ClothListRequestDTO) async throws -> [HomeClothEntity] // MARK: - Initial Images func fetchInitialImages() -> [DraggableImageEntity] diff --git a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift index a9e77b8b..4e632cf8 100644 --- a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift @@ -20,6 +20,23 @@ final class CategoryUseCase { func loadClothItems() -> [HomeClothEntity] { return repository.fetchClothItems() } + + // 새로운 API 기반 메서드 + func loadClothItems( + lastClothId: Int64? = nil, + size: Int = 20, + categoryId: Int64? = nil, + season: String? = nil + ) async throws -> [HomeClothEntity] { + let request = ClothListRequestDTO( + lastClothId: lastClothId, + size: size, + categoryId: categoryId, + season: season + ) + + return try await repository.fetchClothItems(request: request) + } func updateCategories(_ categories: [CategoryEntity]) { repository.saveCategories(categories) diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index d98955f1..f6c798ac 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -73,6 +73,7 @@ struct HomeView: View { } .task { await viewModel.loadWeather(for: nil) + await viewModel.loadActiveCategoriesWithAPI() } .onChange(of: navigationRouter.currentDestination) { newDestination in if newDestination == nil { diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index d83b8e7f..6d3b6ce5 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -75,6 +75,71 @@ final class HomeViewModel: ObservableObject { let clothItems = categoryUseCase.loadClothItems() clothItemsByCategory = Dictionary(grouping: clothItems) { $0.categoryId } } + + // MARK: - 새로운 API 방식 (비동기) + func loadActiveCategoriesWithAPI() async { + let categories = categoryUseCase.loadCategories() + activeCategories = categories + + // 각 카테고리별로 옷 아이템 로드 + var allClothItems: [HomeClothEntity] = [] + + for category in categories { + do { + let items = try await categoryUseCase.loadClothItems( + lastClothId: nil, + size: 20, + categoryId: Int64(category.id), + season: nil + ) + allClothItems.append(contentsOf: items) + } catch { + print("Failed to load cloth items for category \(category.id): \(error)") + } + } + + // 카테고리별로 그룹화 + clothItemsByCategory = Dictionary(grouping: allClothItems) { $0.categoryId } + } + + // MARK: - 특정 카테고리만 로드 + func loadClothItems(for categoryId: Int) async { + do { + let items = try await categoryUseCase.loadClothItems( + lastClothId: nil, + size: 20, + categoryId: Int64(categoryId), + season: nil + ) + + // 해당 카테고리의 아이템 업데이트 + clothItemsByCategory[categoryId] = items + } catch { + print("Failed to load cloth items: \(error)") + } + } + + // MARK: - 페이지네이션 (더 불러오기) + func loadMoreClothItems(for categoryId: Int) async { + guard let existingItems = clothItemsByCategory[categoryId], + let lastItem = existingItems.last else { + return + } + + do { + let newItems = try await categoryUseCase.loadClothItems( + lastClothId: Int64(lastItem.id), + size: 20, + categoryId: Int64(categoryId), + season: nil + ) + + // 기존 아이템에 추가 + clothItemsByCategory[categoryId] = existingItems + newItems + } catch { + print("Failed to load more items: \(error)") + } + } func loadDummyCodi() { codiItems = todayCodiUseCase.loadTodaysCodi() From 528b2666e5d824b598f51d6574bb48e106873af8 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 26 Dec 2025 01:14:06 +0900 Subject: [PATCH 10/35] =?UTF-8?q?[#43]=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=BD=94=EB=94=94=20api=20=EC=97=B0=EA=B2=B0=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 29 +++++++++++------ .../Repositories/HomeRepositoryImpl.swift | 6 ++-- .../Home/Domain/Entities/HomeEntity.swift | 30 ++++++++--------- .../Domain/Protocols/HomeRepository.swift | 2 +- .../Domain/UseCases/CodiBoardUseCase.swift | 22 +++++++------ .../ViewModel/CodiBoardViewModel.swift | 32 +++++++++++-------- 6 files changed, 70 insertions(+), 51 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 80206ae5..a1b821ed 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -200,20 +200,29 @@ final class HomeDatasource { DraggableImageEntity(id: 6, name: "image6", position: CGPoint(x: 250, y: 240), scale: 1.0, rotationAngle: 0.0) ] } - - func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) { - print("===== 📦 Codi Coordinate Request Mock =====") - print("coordinateImageUrl:", request.coordinateImageUrl) - print("Payload count:", request.Payload.count) + + // MARK: - Codi Items (API Mock) + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { + print("===== 📦 Codi Coordinate Request Mock (Server API Call) =====") + print("Snapshot Image URL: \(request.coordinateImageUrl)") + print("Total Items: \(request.Payload.count)") + + // 네트워크 지연 시뮬레이션 + try await Task.sleep(nanoseconds: 500_000_000) + for (index, item) in request.Payload.enumerated() { - let pos = String(format: "(%.1f, %.1f)", item.locationX, item.locationY) - let ratioStr = String(format: "%.2f", item.ratio) - let degreeStr = String(format: "%.2f", item.degree) - print("[\(index)] clothId: \(item.clothId), pos: \(pos), ratio: \(ratioStr), degree: \(degreeStr), order: \(item.order)") + print(""" + [Item \(index)] + - clothId: \(item.clothId) + - position: (\(item.locationX), \(item.locationY)) + - ratio(scale): \(item.ratio) + - degree: \(item.degree) + - order: \(item.order) + """) } - print("===== ✅ Mock request complete =====") + print("===== ✅ Mock Server Response: Success =====") } func loadDummyCodiItems() -> [CodiItemEntity] { diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index 9523a4f5..846123fa 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -49,9 +49,9 @@ final class HomeRepositoryImpl: HomeRepository { dataSource.loadInitialImages() } - func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) { - dataSource.saveCodiCoordinate(request) - } + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { + try await dataSource.saveCodiCoordinate(request) + } func fetchCodiItems() -> [CodiItemEntity] { dataSource.loadDummyCodiItems() diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 45864405..380aa512 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -49,21 +49,6 @@ struct DraggableImageEntity: Identifiable, Hashable { let imageURL: String? = nil } -// MARK: - Codi Coordinate Request (for server) -struct CodiCoordinateRequestDTO: Codable { - let coordinateImageUrl: String - let Payload: [CodiCoordinatePayloadDTO] -} - -struct CodiCoordinatePayloadDTO: Codable { - let clothId: Int64 - let locationX: Double - let locationY: Double - let ratio: Double - let degree: Double - let order: Int -} - // MARK: - Codi Item struct CodiItemEntity: Identifiable { let id: Int @@ -127,3 +112,18 @@ extension Array where Element == ClothListResponseDTO { return self.map { $0.toEntity(categoryId: categoryId) } } } + +// MARK: - Codi Coordinate Request (for server) +struct CodiCoordinateRequestDTO: Codable { + let coordinateImageUrl: String + let Payload: [CodiCoordinatePayloadDTO] +} + +struct CodiCoordinatePayloadDTO: Codable { + let clothId: Int64 + let locationX: Double + let locationY: Double + let ratio: Double + let degree: Double + let order: Int +} diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index c81a058c..6260f509 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -23,7 +23,7 @@ protocol HomeRepository { func fetchInitialImages() -> [DraggableImageEntity] // MARK: - Codi Items - func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws func fetchCodiItems() -> [CodiItemEntity] // MARK: - Date diff --git a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift index f9c6d061..56991be3 100644 --- a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift @@ -5,6 +5,8 @@ // Created by 한금준 on 12/25/25. // +import Foundation + final class CodiBoardUseCase { private let repository: HomeRepository @@ -17,26 +19,28 @@ final class CodiBoardUseCase { return repository.fetchInitialImages() } - func saveCodiItems(_ images: [DraggableImageEntity]) { - // TODO: 실제 스냅샷 URL을 전달받도록 변경 예정 (현재는 mock URL 사용) - let mockCoordinateImageUrl = "https://example.com/mock-today-codi-snapshot.jpg" + func saveCodiItems(_ images: [DraggableImageEntity]) async throws { + // TODO: 실제 캔버스를 캡처한 이미지의 S3 업로드 URL이 이곳에 들어가야 함 + let mockSnapshotUrl = "https://codive-storage.com/previews/\(UUID().uuidString).jpg" + // Entity를 DTO로 변환 (서버 스펙에 맞춤) let payloads: [CodiCoordinatePayloadDTO] = images.enumerated().map { index, image in - CodiCoordinatePayloadDTO( - clothId: Int64(image.id), // 실제 clothId 필드가 생기면 교체 + return CodiCoordinatePayloadDTO( + clothId: Int64(image.id), // 고유 의류 ID locationX: Double(image.position.x), locationY: Double(image.position.y), ratio: Double(image.scale), - degree: image.rotationAngle, - order: index + degree: Double(image.rotationAngle), + order: index // 레이어 순서 (Z-Index가 높을수록 뒤에 위치함) ) } let request = CodiCoordinateRequestDTO( - coordinateImageUrl: mockCoordinateImageUrl, + coordinateImageUrl: mockSnapshotUrl, Payload: payloads ) - repository.saveCodiCoordinate(request) + // Repository를 통해 서버(또는 Mock)에 저장 + try await repository.saveCodiCoordinate(request) } } diff --git a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift index e30a598c..8c32eb20 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -43,20 +43,26 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco // MARK: - Actions func handleConfirmCodi() { - codiBoardUseCase.saveCodiItems(images) - - // 코디 이미지 URL 생성 (실제로는 저장된 이미지의 URL을 가져와야 함) - let imageURL = images.first?.imageURL - - // 뒤로 이동 - navigationRouter.navigateBack() - - // 팝업 표시 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.homeViewModel?.showCompletionPopup(imageURL: imageURL) + Task { + do { + // 서버에 데이터 전송 + try await codiBoardUseCase.saveCodiItems(images) + + // 전송 완료 후 UI 로직 실행 + await MainActor.run { + 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 { + print("코디 저장 실패: \(error.localizedDescription)") + // 필요 시 사용자에게 알림(에러 팝업 등) + } } - - isConfirmed = true } // MARK: - Image Manipulation (DraggableImageViewModelProtocol) From 368eb324332bdb8864aab85dc9d54a30cc1796d2 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 26 Dec 2025 21:25:31 +0900 Subject: [PATCH 11/35] =?UTF-8?q?[#43]=20=EB=A3=A9=EB=B6=81=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Component/AddBottomSheet.swift | 287 ++++++++++++++++++ .../Home/Presentation/View/HomeView.swift | 2 +- .../ViewModel/HomeViewModel.swift | 2 +- 3 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 Codive/Features/Home/Presentation/Component/AddBottomSheet.swift diff --git a/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift new file mode 100644 index 00000000..5f99a4b1 --- /dev/null +++ b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift @@ -0,0 +1,287 @@ +// +// AddBottomSheet.swift +// Codive +// +// Created by 한금준 on 12/26/25. +// + +import SwiftUI + +// MARK: - Reusable BottomSheet + +struct BottomSheet: View { + @Binding var isPresented: Bool + + private let title: String? + private let showsGrabber: Bool + private let onDismiss: (() -> Void)? + private let sheetHeight: CGFloat + + @State private var translationY: CGFloat = 0 + + // MARK: - Initializer + + /// 바텀시트를 구성하는 데 필요한 설정 값들을 주입하는 초기화 함수 + init( + isPresented: Binding, + title: String? = nil, + showsGrabber: Bool = true, + sheetHeight: CGFloat = 500, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { + self._isPresented = isPresented + self.title = title + self.showsGrabber = showsGrabber + self.sheetHeight = sheetHeight + self.onDismiss = onDismiss + self.content = content() + } + + // MARK: - Body + + /// 딤 처리된 배경 위에 바텀시트를 하단 정렬로 배치하는 레이아웃 + var body: some View { + ZStack(alignment: .bottom) { + if isPresented { + // Dimmed background + Color.black + .opacity(0.4) + .ignoresSafeArea() + .onTapGesture { dismiss() } + + sheetBody + .transition(.move(edge: .bottom)) + .animation(.spring(response: 0.35, dampingFraction: 0.9), value: isPresented) + } + } + .ignoresSafeArea(.container, edges: .bottom) + } + + private let content: Content + + // MARK: - Sheet Layout + + /// 상단 그랩바, 타이틀, 컨텐츠를 포함한 바텀시트 내부 레이아웃 + private var sheetBody: some View { + VStack(spacing: 0) { + if showsGrabber { + Capsule() + .fill(Color(.systemGray4)) + .frame(width: 69, height: 5) + .padding(.top, 12) + } + + if let title = title { + Text(title) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.top, 20) + .padding(.bottom, 24) + } + + VStack(spacing: 0) { + content + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + + Color.clear.frame(height: 34) + } + .frame(width: UIScreen.main.bounds.width) + .frame(height: sheetHeight) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(.systemBackground)) + ) + .offset(y: translationY) + .gesture(dragGesture) + } + + // MARK: - Gesture + + /// 바텀시트를 아래로 드래그하여 닫을 수 있도록 처리하는 드래그 제스처 + private var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + if value.translation.height > 0 { + translationY = value.translation.height + } + } + .onEnded { value in + if value.translation.height > 120 { + dismiss() + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + translationY = 0 + } + } + } + } + + // MARK: - Private Helpers + + /// 바텀시트를 닫고, onDismiss 콜백을 실행하는 헬퍼 함수 + private func dismiss() { + withAnimation { isPresented = false } + onDismiss?() + translationY = 0 + } +} + +// MARK: - LookBook Bottom Sheet Entity + +/// 룩북 선택 바텀시트에 표시될 항목 정보를 담는 엔티티 +struct LookBookBottomSheetEntity: Identifiable, Hashable { + let lookbookId: Int + let codiId: Int + let imageUrl: String + let title: String + let count: Int + + /// ForEach 에 사용하기 위한 고유 id (룩북 기준) + var id: Int { lookbookId } +} + +// MARK: - LookBook Card View + +/// 썸네일, 제목, 코디 개수를 보여주는 룩북 카드 컴포넌트 +struct LookBookSheetCardView: View { + let entity: LookBookBottomSheetEntity + /// 외부에서 주입받는 썸네일 뷰 (예: AsyncImage, Kingfisher, 로컬 Image 등) + let thumbnail: Thumbnail? + /// 카드 전체를 탭했을 때 실행할 액션 + let onTap: (() -> Void)? + + // MARK: - Body + + /// 카드 전체를 버튼으로 감싸 탭 시 onTap 클로저를 호출하는 레이아웃 + var body: some View { + Button { + onTap?() + } label: { + HStack(spacing: 13) { + ZStack { + RoundedRectangle(cornerRadius: 11.18, style: .continuous) + .fill(Color.Codive.grayscale5) + .frame(width: 76, height: 76) + + if let thumbnail { + thumbnail + .frame(width: 76, height: 76) + .clipShape(RoundedRectangle(cornerRadius: 11.18)) + } else { + defaultImage + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(entity.title) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + + Text("\(entity.count)") + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale4) + } + + Spacer() + } + .padding(.leading, 8) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .stroke(Color.Codive.grayscale5, lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + } + + // MARK: - Placeholder + + /// 썸네일이 없거나 로딩 실패 시 표시할 기본 이미지 + private var defaultImage: some View { + Image(systemName: "photo") + .resizable() + .scaledToFit() + .padding(12) + .foregroundStyle(Color(.systemGray3)) + } +} + +// MARK: - AddBottomSheet (룩북 선택 바텀시트) + +/// 룩북 리스트를 그리드 형태로 보여주고, 선택 시 콜백을 전달하는 바텀시트 +struct AddBottomSheet: View { + @Binding var isPresented: Bool + /// 룩북 바텀시트에 표시할 엔티티 리스트 + let entities: [LookBookBottomSheetEntity] + /// 각 엔티티에 대한 썸네일 뷰를 외부에서 주입 (예: AsyncImage, KFImage 등) + let thumbnailProvider: (LookBookBottomSheetEntity) -> Thumbnail? + /// 엔티티 선택 시 콜백 (lookbookId, codiId 등을 상위에서 활용) + let onTapEntity: (LookBookBottomSheetEntity) -> Void + + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + // MARK: - Body + + /// 공통 BottomSheet 위에 룩북 카드들을 2열 그리드로 배치하는 레이아웃 + var body: some View { + BottomSheet( + isPresented: $isPresented, + title: "룩북에 추가", + sheetHeight: UIScreen.main.bounds.height * 0.75 + ) { + ScrollView(showsIndicators: false) { + LazyVGrid(columns: columns, alignment: .center, spacing: 16) { + ForEach(entities) { entity in + LookBookSheetCardView( + entity: entity, + thumbnail: thumbnailProvider(entity), + onTap: { onTapEntity(entity) } + ) + } + } + .padding(.vertical, 8) + } + } + } +} + +// MARK: - Preview + +/// 룩북 선택 바텀시트의 레이아웃과 스타일을 확인하기 위한 프리뷰 +struct AddBottomSheet_Previews: PreviewProvider { + static var previews: some View { + let sampleEntities: [LookBookBottomSheetEntity] = [ + .init(lookbookId: 1, codiId: 101, imageUrl: "https://example.com/1.png", title: "운동룩", count: 6), + .init(lookbookId: 2, codiId: 102, imageUrl: "https://example.com/2.png", title: "출근룩", count: 12), + .init(lookbookId: 3, codiId: 103, imageUrl: "https://example.com/3.png", title: "데이트룩", count: 16), + .init(lookbookId: 4, codiId: 104, imageUrl: "https://example.com/4.png", title: "독서실룩", count: 8), + .init(lookbookId: 5, codiId: 105, imageUrl: "https://example.com/5.png", title: "스페인여행", count: 20) + ] + + ZStack { + Color(.systemGray5) + .ignoresSafeArea() + + AddBottomSheet( + isPresented: .constant(true), + entities: sampleEntities, + thumbnailProvider: { _ in + Image(systemName: "photo") + .resizable() + .scaledToFill() + }, + onTapEntity: { _ in } + ) + } + } +} diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index f6c798ac..50e5b0ea 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -54,7 +54,7 @@ struct HomeView: View { } } .id(scrollViewID) // 스크롤 초기화를 위한 ID - .padding(.bottom, 72) + .padding(.bottom, 80) } .background(alignment: .center) { Color.white diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 6d3b6ce5..49edb40a 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -14,7 +14,7 @@ import CoreLocation final class HomeViewModel: ObservableObject { // MARK: - Properties - @Published var hasCodi: Bool = false + @Published var hasCodi: Bool = true @Published var selectedIndex: Int? = 0 @Published var showClothSelector: Bool = false @Published var titleFrame: CGRect = .zero From e1f8414233dac3ea5daa46bce76a8182643316c9 Mon Sep 17 00:00:00 2001 From: Funital Date: Sat, 27 Dec 2025 00:12:23 +0900 Subject: [PATCH 12/35] =?UTF-8?q?[#43]=20HomeViewModel=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Home/Presentation/ViewModel/HomeViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 49edb40a..d9badc31 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -206,11 +206,11 @@ final class HomeViewModel: ObservableObject { // MARK: - Feature Placeholders func rememberCodi() {} - func selectEditCodi() {} - - func addLookbook() { + func selectEditCodi() { navigationRouter.navigate(to: .lookbook) } + func addLookbook() {} + func sharedCodi() {} } From 349649286461516b9a96c95e5eecf0156915c2e4 Mon Sep 17 00:00:00 2001 From: Funital Date: Sat, 27 Dec 2025 00:58:56 +0900 Subject: [PATCH 13/35] =?UTF-8?q?[#43]=20=EB=B0=94=ED=85=80=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EA=B2=B0=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/DIContainer/HomeDIContainer.swift | 7 ++- .../Data/DataSources/HomeDatasource.swift | 12 +++++ .../Repositories/HomeRepositoryImpl.swift | 4 ++ .../Home/Domain/Entities/HomeEntity.swift | 13 +++++ .../Domain/Protocols/HomeRepository.swift | 2 + .../UseCases/AddToLookBookUseCase.swift | 19 +++++++ .../Component/AddBottomSheet.swift | 14 ------ .../Home/Presentation/View/HomeView.swift | 6 +-- .../ViewModel/HomeViewModel.swift | 29 ++++++++++- Codive/Features/Main/View/MainTabView.swift | 50 ++++++++++++++----- 10 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift diff --git a/Codive/DIContainer/HomeDIContainer.swift b/Codive/DIContainer/HomeDIContainer.swift index c4eb2246..5b38a29b 100644 --- a/Codive/DIContainer/HomeDIContainer.swift +++ b/Codive/DIContainer/HomeDIContainer.swift @@ -56,6 +56,10 @@ final class HomeDIContainer { DateUseCase(repository: homeRepository) } + func makeAddToLookBookUseCase() -> AddToLookBookUseCase { + AddToLookBookUseCase(repository: homeRepository) + } + // MARK: - Initializer init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter @@ -73,7 +77,8 @@ final class HomeDIContainer { fetchWeatherUseCase: makeFetchWeatherUseCase(), todayCodiUseCase: makeTodayCodiUseCase(), dateUseCase: makeDateUseCase(), - categoryUseCase: makeCategoryUseCase() + categoryUseCase: makeCategoryUseCase(), + addToLookBookUseCase: makeAddToLookBookUseCase() ) homeViewModel = viewModel diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index a1b821ed..fcad86e8 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -244,4 +244,16 @@ final class HomeDatasource { let todayString = formatter.string(from: Date()) return DateEntity(formattedDate: todayString) } + + // MARK: - LookBook (API Mock) + func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] { + // 네트워크 지연 시뮬레이션 + try await Task.sleep(nanoseconds: 300_000_000) + + return [ + LookBookBottomSheetEntity(lookbookId: 1, codiId: 101, imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800", title: "운동룩", count: 6), + LookBookBottomSheetEntity(lookbookId: 2, codiId: 102, imageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800", title: "출근룩", count: 12), + LookBookBottomSheetEntity(lookbookId: 3, codiId: 103, imageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800", title: "데이트룩", count: 16) + ] + } } diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index 846123fa..333fbf8e 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -61,4 +61,8 @@ final class HomeRepositoryImpl: HomeRepository { func getToday() -> DateEntity { dataSource.fetchToday() } + + func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] { + return try await dataSource.fetchLookBookList() + } } diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 380aa512..4433774b 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -127,3 +127,16 @@ struct CodiCoordinatePayloadDTO: Codable { let degree: Double let order: Int } + +// MARK: - LookBook Bottom Sheet Entity + +/// 룩북 선택 바텀시트에 표시될 항목 정보를 담는 엔티티 +// MARK: - LookBook BottomSheet Entity +struct LookBookBottomSheetEntity: Identifiable, Codable { + var id = UUID() + let lookbookId: Int + let codiId: Int + let imageUrl: String + let title: String + let count: Int +} diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 6260f509..5a667210 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -28,4 +28,6 @@ protocol HomeRepository { // MARK: - Date func getToday() -> DateEntity + + func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] } diff --git a/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift new file mode 100644 index 00000000..7646b2c7 --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift @@ -0,0 +1,19 @@ +// +// AddToLookBookUseCase.swift +// Codive +// +// Created by 한금준 on 12/27/25. +// + +final class AddToLookBookUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func execute() async throws -> [LookBookBottomSheetEntity] { + return try await repository.fetchLookBookList() + } +} diff --git a/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift index 5f99a4b1..43d0e074 100644 --- a/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift +++ b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift @@ -129,20 +129,6 @@ struct BottomSheet: View { } } -// MARK: - LookBook Bottom Sheet Entity - -/// 룩북 선택 바텀시트에 표시될 항목 정보를 담는 엔티티 -struct LookBookBottomSheetEntity: Identifiable, Hashable { - let lookbookId: Int - let codiId: Int - let imageUrl: String - let title: String - let count: Int - - /// ForEach 에 사용하기 위한 고유 id (룩북 기준) - var id: Int { lookbookId } -} - // MARK: - LookBook Card View /// 썸네일, 제목, 코디 개수를 보여주는 룩북 카드 컴포넌트 diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index 50e5b0ea..d9369d64 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -10,14 +10,14 @@ import CoreLocation struct HomeView: View { private let homeDIContainer: HomeDIContainer - @StateObject private var viewModel: HomeViewModel + @ObservedObject var viewModel: HomeViewModel @ObservedObject private var navigationRouter: NavigationRouter @State private var scrollViewID = UUID() - init(homeDIContainer: HomeDIContainer) { + init(homeDIContainer: HomeDIContainer, viewModel: HomeViewModel) { self.homeDIContainer = homeDIContainer + self.viewModel = viewModel self._navigationRouter = ObservedObject(wrappedValue: homeDIContainer.navigationRouter) - _viewModel = StateObject(wrappedValue: homeDIContainer.makeHomeViewModel()) } var body: some View { diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index d9badc31..a787e6c4 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -30,6 +30,10 @@ final class HomeViewModel: ObservableObject { @Published var showCompletePopUp: Bool = false @Published var completedCodiImageURL: String? + // 바텀시트 관련 프로퍼티 추가 + @Published var showLookBookSheet: Bool = false + @Published var lookBookList: [LookBookBottomSheetEntity] = [] + let navigationRouter: NavigationRouter // MARK: - UseCases @@ -37,6 +41,7 @@ final class HomeViewModel: ObservableObject { private let todayCodiUseCase: TodayCodiUseCase private let dateUseCase: DateUseCase private let categoryUseCase: CategoryUseCase + private let addToLookBookUseCase: AddToLookBookUseCase // MARK: - Initializer init( @@ -44,13 +49,15 @@ final class HomeViewModel: ObservableObject { fetchWeatherUseCase: FetchWeatherUseCase, todayCodiUseCase: TodayCodiUseCase, dateUseCase: DateUseCase, - categoryUseCase: CategoryUseCase + categoryUseCase: CategoryUseCase, + addToLookBookUseCase: AddToLookBookUseCase ) { self.navigationRouter = navigationRouter self.fetchWeatherUseCase = fetchWeatherUseCase self.todayCodiUseCase = todayCodiUseCase self.dateUseCase = dateUseCase self.categoryUseCase = categoryUseCase + self.addToLookBookUseCase = addToLookBookUseCase loadDummyCodi() loadToday() @@ -210,7 +217,25 @@ final class HomeViewModel: ObservableObject { navigationRouter.navigate(to: .lookbook) } - func addLookbook() {} + func addLookbook() { + print("DEBUG: addLookbook() called") // 호출 여부 확인 + Task { + do { + let list = try await addToLookBookUseCase.execute() + self.lookBookList = list + self.showLookBookSheet = true + print("DEBUG: showLookBookSheet set to true, list count: \(list.count)") + } catch { + print("DEBUG: Failed to load lookbooks: \(error)") + } + } + } + + func selectLookBook(_ entity: LookBookBottomSheetEntity) { + print("Selected LookBook ID: \(entity.lookbookId)") + showLookBookSheet = false + // 추가 성공 팝업 등을 띄우는 로직으로 이어질 수 있음 + } func sharedCodi() {} } diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 61b360a7..d0b360e5 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -12,6 +12,7 @@ struct MainTabView: View { // MARK: - Properties @StateObject private var viewModel: MainTabViewModel @ObservedObject private var navigationRouter: NavigationRouter + @ObservedObject private var homeViewModel: HomeViewModel private let appDIContainer: AppDIContainer private let addDIContainer: AddDIContainer private let homeDIContainer: HomeDIContainer @@ -37,26 +38,30 @@ struct MainTabView: View { self._navigationRouter = ObservedObject(wrappedValue: appDIContainer.navigationRouter) let viewModel = MainTabViewModel(navigationRouter: appDIContainer.navigationRouter) self._viewModel = StateObject(wrappedValue: viewModel) + self.homeViewModel = homeDIContainer.makeHomeViewModel() } // MARK: - Body var body: some View { NavigationStack(path: $navigationRouter.path) { - VStack(spacing: 0) { - if shouldShowTopBar { - TopNavigationBar( - showSearchButton: showSearchButton, - showNotificationButton: showNotificationButton, - onSearchTap: viewModel.handleSearchTap, - onNotificationTap: viewModel.handleNotificationTap - ) - } + // 최상위 ZStack: 여기서 시트를 띄워야 전체(상단바 포함)를 덮습니다. + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + // 1. 상단바 + if shouldShowTopBar { + TopNavigationBar( + showSearchButton: showSearchButton, + showNotificationButton: showNotificationButton, + onSearchTap: viewModel.handleSearchTap, + onNotificationTap: viewModel.handleNotificationTap + ) + } - ZStack(alignment: .bottom) { + // 2. 메인 콘텐츠 영역 Group { switch viewModel.selectedTab { case .home: - HomeView(homeDIContainer: homeDIContainer) + HomeView(homeDIContainer: homeDIContainer, viewModel: homeViewModel) .ignoresSafeArea(.all, edges: .bottom) case .closet: ClosetView(closetDIContainer: closetDIContainer) @@ -71,10 +76,29 @@ struct MainTabView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) - // MARK: - Tab Bar + // 3. 하단 탭바 TabBar(selectedTab: $viewModel.selectedTab) .zIndex(shouldShowTabBar ? 1 : 0) - .allowsHitTesting(shouldShowTabBar) + } + + // 4. 바텀시트: VStack(상단바+컨텐츠+탭바) 위에 배치하여 전체를 딤 처리 + if homeViewModel.showLookBookSheet { + AddBottomSheet( + isPresented: $homeViewModel.showLookBookSheet, + entities: homeViewModel.lookBookList, + thumbnailProvider: { entity in + AsyncImage(url: URL(string: entity.imageUrl)) { image in + image.resizable().scaledToFill() + } placeholder: { + ProgressView() + } + }, + onTapEntity: { entity in + homeViewModel.selectLookBook(entity) + } + ) + .zIndex(100) // 가장 높은 숫자로 설정 + .transition(.move(edge: .bottom)) } } .navigationDestination(for: AppDestination.self) { destination in From b3bb867d0c6073697b87fd65320d322928fdf500 Mon Sep 17 00:00:00 2001 From: Funital Date: Sat, 27 Dec 2025 04:15:51 +0900 Subject: [PATCH 14/35] =?UTF-8?q?[#43]=20HomeView=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=9C=EA=B7=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 33 +++++++-- .../Home/Domain/Entities/HomeEntity.swift | 12 +++- .../Presentation/View/HomeHasCodiView.swift | 67 ++++++++++++++----- .../Home/Presentation/View/HomeView.swift | 61 ++++++++--------- .../ViewModel/HomeViewModel.swift | 19 ++++++ 5 files changed, 138 insertions(+), 54 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index fcad86e8..d9072ba8 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -227,12 +227,33 @@ final class HomeDatasource { func loadDummyCodiItems() -> [CodiItemEntity] { return [ - CodiItemEntity(id: 1, imageName: "image1", x: 80, y: 80, width: 80, height: 80), - CodiItemEntity(id: 2, imageName: "image2", x: 160, y: 120, width: 90, height: 90), - CodiItemEntity(id: 3, imageName: "image3", x: 240, y: 160, width: 100, height: 100), - CodiItemEntity(id: 4, imageName: "image4", x: 120, y: 240, width: 70, height: 70), - CodiItemEntity(id: 5, imageName: "image5", x: 200, y: 280, width: 120, height: 120), - CodiItemEntity(id: 6, imageName: "image6", x: 250, y: 240, width: 110, height: 110) + // 상의 (왼쪽 위 근처) + CodiItemEntity( + id: 2, + imageName: "image4", + x: 100, // ← 캔버스 가로의 약 25% + y: 100, // ← 세로의 약 25% + width: 90, + height: 90 + ), + // 바지 (오른쪽 중간쯤) + CodiItemEntity( + id: 3, + imageName: "image3", + x: 300, // ← 가로 75% + y: 200, // ← 세로 35% 근처 + width: 100, + height: 100 + ), + // 신발 (왼쪽 아래 근처) + CodiItemEntity( + id: 4, + imageName: "image2", + x: 150, // ← 가로 35% 근처 + y: 300, // ← 세로 75% + width: 70, + height: 70 + ) ] } diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 4433774b..bb6b6334 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -128,9 +128,6 @@ struct CodiCoordinatePayloadDTO: Codable { let order: Int } -// MARK: - LookBook Bottom Sheet Entity - -/// 룩북 선택 바텀시트에 표시될 항목 정보를 담는 엔티티 // MARK: - LookBook BottomSheet Entity struct LookBookBottomSheetEntity: Identifiable, Codable { var id = UUID() @@ -140,3 +137,12 @@ struct LookBookBottomSheetEntity: Identifiable, Codable { let title: String let count: Int } + +// MARK: - Cloth Tag Entity +struct ClothTagEntity: Identifiable, Hashable { + let id = UUID() + let brand: String + let content: String + var locationX: CGFloat // 0.0 ~ 1.0 + var locationY: CGFloat // 0.0 ~ 1.0 +} diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index 1ec443e5..0fe4802b 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -10,24 +10,24 @@ import SwiftUI struct HomeHasCodiView: View { @ObservedObject var viewModel: HomeViewModel let width: CGFloat - + var body: some View { ZStack(alignment: .topTrailing) { - + VStack(alignment: .leading, spacing: 16) { header codiDisplayArea - + if viewModel.showClothSelector { clothSelector } - + CustomBanner(text: TextLiteral.Home.bannerTitle) { viewModel.rememberCodi() } .padding() } - + CustomOverflowMenu( menuType: .coordination, menuActions: [ @@ -39,7 +39,7 @@ struct HomeHasCodiView: View { .zIndex(9999) } } - + private var header: some View { HStack { Text("\(TextLiteral.Home.todayCodiTitle)(\(viewModel.todayString))") @@ -48,7 +48,7 @@ struct HomeHasCodiView: View { Spacer() } } - + private var codiDisplayArea: some View { ZStack(alignment: .bottomLeading) { RoundedRectangle(cornerRadius: 15) @@ -62,15 +62,52 @@ struct HomeHasCodiView: View { .stroke(Color.gray.opacity(0.4), lineWidth: 1) } .padding(.horizontal, 20) - + ForEach(viewModel.codiItems) { item in - Image(item.imageName) - .resizable() - .scaledToFit() - .frame(width: item.width, height: item.height) - .position(x: item.x, y: item.y) + ZStack { + // 1. 실제 이미지 표시 + Image(item.imageName) + .resizable() + .scaledToFit() + .frame(width: item.width, height: item.height) + .overlay( + // 2. GeometryReader를 사용하여 이미지 내부 좌표계 확보 + GeometryReader { proxy in + let imageSize = proxy.size + + // 3. 선택된 아이템인 경우에만 태그 표시 + if viewModel.selectedItemID == item.id { + ForEach(viewModel.selectedItemTags) { tag in + CustomTagView(type: .basic( + title: tag.brand, + content: tag.content + )) + // 4. 절대 좌표 계산: 상대좌표 * 실제크기 + .position( + x: tag.locationX * imageSize.width, + y: tag.locationY * imageSize.height + ) + // 5. 드래그를 통한 위치 업데이트 (선택 사항) + .gesture( + DragGesture() + .onChanged { value in + viewModel.updateTagPosition( + tagId: tag.id, + x: value.location.x, + y: value.location.y, + imageSize: imageSize + ) + } + ) + } + } + } + ) + } + // 전체 캔버스에서의 위치 + .position(x: item.x, y: item.y) } - + Button(action: viewModel.toggleClothSelector) { Image("ic_tag") .resizable() @@ -80,7 +117,7 @@ struct HomeHasCodiView: View { .padding(.bottom, 16) } } - + private var clothSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index d9369d64..d9ee292b 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -23,42 +23,43 @@ struct HomeView: View { var body: some View { GeometryReader { outerGeometry in ZStack { - VStack(spacing: 0) { - ScrollView { - VStack { - if let weather = viewModel.weatherData { - WeatherCardView(weatherData: weather) - .padding(.horizontal, 20) + // 전체 배경 + Color.white + .ignoresSafeArea() + + ScrollView { + VStack { + // 날씨 카드 + if let weather = viewModel.weatherData { + WeatherCardView(weatherData: weather) + .padding(.horizontal, 20) + .padding(.top, 16) + } else { + if let errorMessage = viewModel.weatherErrorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .multilineTextAlignment(.center) .padding(.top, 16) + .padding(.horizontal, 20) } else { - if let errorMessage = viewModel.weatherErrorMessage { - Text(errorMessage) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.top, 16) - .padding(.horizontal, 20) - } else { - ProgressView(TextLiteral.Home.weatherLoading) - .padding(.top, 16) - } - } - - if viewModel.hasCodi { - HomeHasCodiView( - viewModel: viewModel, - width: outerGeometry.size.width - ) - } else { - HomeNoCodiView(viewModel: viewModel) + ProgressView(TextLiteral.Home.weatherLoading) + .padding(.top, 16) } } + + // 코디 여부에 따라 다른 뷰 + if viewModel.hasCodi { + HomeHasCodiView( + viewModel: viewModel, + width: outerGeometry.size.width + ) + } else { + HomeNoCodiView(viewModel: viewModel) + } } - .id(scrollViewID) // 스크롤 초기화를 위한 ID - .padding(.bottom, 80) - } - .background(alignment: .center) { - Color.white + .padding(.bottom, 16) // 필요하면 살짝만 여백 } + .id(scrollViewID) // 팝업 오버레이 if viewModel.showCompletePopUp { diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index a787e6c4..c5a8bc14 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -23,6 +23,7 @@ final class HomeViewModel: ObservableObject { @Published var todayString: String = "" @Published var selectedItemID: Int? @Published var codiItems: [CodiItemEntity] = [] + @Published var selectedItemTags: [ClothTagEntity] = [] @Published var activeCategories: [CategoryEntity] = [] @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] @@ -168,8 +169,26 @@ final class HomeViewModel: ObservableObject { selectedIndex = index } + // selectItem 메서드 수정 func selectItem(_ id: Int?) { selectedItemID = id + if let id = id { + // 실제로는 repository를 통해 해당 clothId의 태그 리스트를 가져와야 합니다. + // 여기서는 조건에 맞는 Mock 데이터를 생성합니다. + self.selectedItemTags = [ + ClothTagEntity(brand: "Brand Name", content: "texttexttexttexttexttext...", locationX: 0.5, locationY: 0.3) + ] + } else { + self.selectedItemTags = [] + } + } + + // 태그 위치 업데이트 (드래그 시 사용) + 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 handleSearchTap() {} From 409a251728f97a45a91e380854974a621581d1f2 Mon Sep 17 00:00:00 2001 From: Funital Date: Sat, 27 Dec 2025 04:54:19 +0900 Subject: [PATCH 15/35] =?UTF-8?q?[#43]=20=ED=83=9C=EA=B7=B8=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=B0=8F=20mock=20data=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 17 ++++++++++++---- .../Home/Domain/Entities/HomeEntity.swift | 13 ++++++++---- .../Presentation/View/HomeHasCodiView.swift | 20 +++++-------------- .../ViewModel/HomeViewModel.swift | 19 ++++++++++++------ .../Presentation/View/CodiDetailView.swift | 3 +++ 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index d9072ba8..c3bcd2a7 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -231,15 +231,21 @@ final class HomeDatasource { CodiItemEntity( id: 2, imageName: "image4", + clothName: "체크 셔츠", + brandName: "Polo", + description: "사계절 착용 가능한 셔츠", x: 100, // ← 캔버스 가로의 약 25% y: 100, // ← 세로의 약 25% - width: 90, - height: 90 + width: 70, + height: 70 ), // 바지 (오른쪽 중간쯤) CodiItemEntity( id: 3, imageName: "image3", + clothName: "와이드 치노 팬츠", + brandName: "Basic Concept", + description: "사계절 착용 가능한 면 바지", x: 300, // ← 가로 75% y: 200, // ← 세로 35% 근처 width: 100, @@ -249,10 +255,13 @@ final class HomeDatasource { CodiItemEntity( id: 4, imageName: "image2", + clothName: "Dr.Martens", + brandName: "Codive Studio", + description: "구두", x: 150, // ← 가로 35% 근처 y: 300, // ← 세로 75% - width: 70, - height: 70 + width: 90, + height: 90 ) ] } diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index bb6b6334..94fa55ee 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -53,6 +53,9 @@ struct DraggableImageEntity: Identifiable, Hashable { struct CodiItemEntity: Identifiable { let id: Int let imageName: String + let clothName: String // 추가: 옷 이름 + let brandName: String // 추가: 브랜드 이름 + let description: String // 추가: 옷 설명 let x: CGFloat let y: CGFloat let width: CGFloat @@ -141,8 +144,10 @@ struct LookBookBottomSheetEntity: Identifiable, Codable { // MARK: - Cloth Tag Entity struct ClothTagEntity: Identifiable, Hashable { let id = UUID() - let brand: String - let content: String - var locationX: CGFloat // 0.0 ~ 1.0 - var locationY: CGFloat // 0.0 ~ 1.0 + let title: String // 기존 brand에서 변경 (더 범용적) + let content: String // 기존 content 유지 + var locationX: CGFloat + var locationY: CGFloat + // 추가: 이미지 위치에 따른 방향 계산을 위한 프로퍼티 + var isRightSide: Bool = true } diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index 0fe4802b..ff74adb9 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -75,30 +75,20 @@ struct HomeHasCodiView: View { GeometryReader { proxy in let imageSize = proxy.size - // 3. 선택된 아이템인 경우에만 태그 표시 if viewModel.selectedItemID == item.id { ForEach(viewModel.selectedItemTags) { tag in CustomTagView(type: .basic( - title: tag.brand, + title: tag.title, content: tag.content )) - // 4. 절대 좌표 계산: 상대좌표 * 실제크기 .position( x: tag.locationX * imageSize.width, y: tag.locationY * imageSize.height ) - // 5. 드래그를 통한 위치 업데이트 (선택 사항) - .gesture( - DragGesture() - .onChanged { value in - viewModel.updateTagPosition( - tagId: tag.id, - x: value.location.x, - y: value.location.y, - imageSize: imageSize - ) - } - ) + // 이미지 위치에 따른 좌우 반전 오프셋 적용 + // 태그 자체의 너비만큼 왼쪽 혹은 오른쪽으로 밀어줌 + .offset(x: tag.isRightSide ? 120 : -120) + .animation(.spring(), value: tag.isRightSide) } } } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index c5a8bc14..a3703c82 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -168,15 +168,22 @@ final class HomeViewModel: ObservableObject { func selectCloth(at index: Int) { selectedIndex = index } - - // selectItem 메서드 수정 + func selectItem(_ id: Int?) { selectedItemID = id - if let id = id { - // 실제로는 repository를 통해 해당 clothId의 태그 리스트를 가져와야 합니다. - // 여기서는 조건에 맞는 Mock 데이터를 생성합니다. + if let id = id, let item = codiItems.first(where: { $0.id == id }) { + // 이미지 중심 좌표(item.x)가 화면 중앙보다 오른쪽인지 왼쪽인지 판단 + // (기준을 200으로 잡거나 UIScreen.main.bounds.width / 2로 설정) + let isImageOnRight = item.x > 200 + self.selectedItemTags = [ - ClothTagEntity(brand: "Brand Name", content: "texttexttexttexttexttext...", locationX: 0.5, locationY: 0.3) + ClothTagEntity( + title: item.brandName, + content: item.clothName, // 또는 item.description + locationX: 0.5, + locationY: 0.5, + isRightSide: !isImageOnRight // 이미지가 오른쪽이면 태그는 왼쪽(false) + ) ] } else { self.selectedItemTags = [] diff --git a/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift b/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift index cd99379e..4ca450de 100644 --- a/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift +++ b/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift @@ -159,6 +159,9 @@ struct CodiDetailView: View { entity: CodiItemEntity( id: item.id, imageName: item.imageName, + clothName: "", + brandName: "", + description: "", x: 0, y: 0, width: 68, From 1054b09c24ddd01ebed602fdc15b80edf421ce94 Mon Sep 17 00:00:00 2001 From: Funital Date: Sat, 27 Dec 2025 05:22:54 +0900 Subject: [PATCH 16/35] =?UTF-8?q?[#43]=20=ED=83=9C=EA=B7=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 12 +++ .../Presentation/View/HomeHasCodiView.swift | 91 +++++++++---------- .../ViewModel/HomeViewModel.swift | 42 +++++---- 3 files changed, 80 insertions(+), 65 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index c3bcd2a7..fa98ba6e 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -227,6 +227,18 @@ final class HomeDatasource { func loadDummyCodiItems() -> [CodiItemEntity] { return [ + // 상의 (왼쪽 위 근처) + CodiItemEntity( + id: 1, + imageName: "image1", + clothName: "모자", + brandName: "Polo", + description: "사계절 착용 가능한 모자", + x: 300, // ← 캔버스 가로의 약 25% + y: 100, // ← 세로의 약 25% + width: 70, + height: 70 + ), // 상의 (왼쪽 위 근처) CodiItemEntity( id: 2, diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index ff74adb9..a3a54443 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -48,64 +48,59 @@ struct HomeHasCodiView: View { Spacer() } } - + private var codiDisplayArea: some View { - ZStack(alignment: .bottomLeading) { - RoundedRectangle(cornerRadius: 15) - .fill(Color.Codive.grayscale7) - .frame( - width: max(width - 40, 0), - height: max(width - 40, 0) - ) - .overlay(alignment: .center) { - RoundedRectangle(cornerRadius: 15) - .stroke(Color.gray.opacity(0.4), lineWidth: 1) - } - .padding(.horizontal, 20) + // 가장 바깥쪽 ZStack에 GeometryReader를 사용하여 전체 캔버스 크기를 잡습니다. + GeometryReader { canvasProxy in + let canvasSize = canvasProxy.size - ForEach(viewModel.codiItems) { item in - ZStack { - // 1. 실제 이미지 표시 + ZStack(alignment: .bottomLeading) { + // 1. 배경 사각형 + RoundedRectangle(cornerRadius: 15) + .fill(Color.Codive.grayscale7) + .frame(width: canvasSize.width, height: canvasSize.height) + .overlay { + RoundedRectangle(cornerRadius: 15) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + } + + // 2. 코디 아이템들 (이미지 레이어) + ForEach(viewModel.codiItems) { item in Image(item.imageName) .resizable() .scaledToFit() .frame(width: item.width, height: item.height) - .overlay( - // 2. GeometryReader를 사용하여 이미지 내부 좌표계 확보 - GeometryReader { proxy in - let imageSize = proxy.size - - if viewModel.selectedItemID == item.id { - ForEach(viewModel.selectedItemTags) { tag in - CustomTagView(type: .basic( - title: tag.title, - content: tag.content - )) - .position( - x: tag.locationX * imageSize.width, - y: tag.locationY * imageSize.height - ) - // 이미지 위치에 따른 좌우 반전 오프셋 적용 - // 태그 자체의 너비만큼 왼쪽 혹은 오른쪽으로 밀어줌 - .offset(x: tag.isRightSide ? 120 : -120) - .animation(.spring(), value: tag.isRightSide) - } - } - } + .position(x: item.x, y: item.y) + } + + if let selectedID = viewModel.selectedItemID, + let selectedItem = viewModel.codiItems.first(where: { $0.id == selectedID }) { + + ForEach(viewModel.selectedItemTags) { tag in + CustomTagView(type: .basic( + title: tag.title, + content: tag.content + )) + .position( + x: selectedItem.x + (tag.locationX - 0.5) * selectedItem.width, + y: selectedItem.y + (tag.locationY - 0.5) * selectedItem.height ) + .offset(x: tag.isRightSide ? 120 : -120) + .transition(.opacity.combined(with: .scale)) + .zIndex(100) // 명시적으로 높은 zIndex 부여 + } } - // 전체 캔버스에서의 위치 - .position(x: item.x, y: item.y) - } - - Button(action: viewModel.toggleClothSelector) { - Image("ic_tag") - .resizable() - .frame(width: 28, height: 28) + + Button(action: viewModel.toggleClothSelector) { + Image("ic_tag") + .resizable() + .frame(width: 28, height: 28) + } + .padding([.leading, .bottom], 16) } - .padding(.leading, 36) - .padding(.bottom, 16) } + .frame(width: max(width - 40, 0), height: max(width - 40, 0)) + .padding(.horizontal, 20) } private var clothSelector: some View { diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index a3703c82..c9bb39c2 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -162,6 +162,12 @@ final class HomeViewModel: ObservableObject { func toggleClothSelector() { withAnimation(.spring()) { showClothSelector.toggle() + + // 셀렉터가 닫힐 때(false가 될 때) 선택된 아이템 정보 초기화 + if !showClothSelector { + selectedItemID = nil + selectedItemTags = [] + } } } @@ -170,23 +176,25 @@ final class HomeViewModel: ObservableObject { } func selectItem(_ id: Int?) { - selectedItemID = id - if let id = id, let item = codiItems.first(where: { $0.id == id }) { - // 이미지 중심 좌표(item.x)가 화면 중앙보다 오른쪽인지 왼쪽인지 판단 - // (기준을 200으로 잡거나 UIScreen.main.bounds.width / 2로 설정) - let isImageOnRight = item.x > 200 - - self.selectedItemTags = [ - ClothTagEntity( - title: item.brandName, - content: item.clothName, // 또는 item.description - locationX: 0.5, - locationY: 0.5, - isRightSide: !isImageOnRight // 이미지가 오른쪽이면 태그는 왼쪽(false) - ) - ] - } else { - self.selectedItemTags = [] + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedItemID = id + if let id = id, let item = codiItems.first(where: { $0.id == id }) { + // 이미지 중심 좌표(item.x)가 화면 중앙보다 오른쪽인지 왼쪽인지 판단 + // (기준을 200으로 잡거나 UIScreen.main.bounds.width / 2로 설정) + let isImageOnRight = item.x > 200 + + self.selectedItemTags = [ + ClothTagEntity( + title: item.brandName, + content: item.clothName, // 또는 item.description + locationX: 0.5, + locationY: 0.5, + isRightSide: !isImageOnRight // 이미지가 오른쪽이면 태그는 왼쪽(false) + ) + ] + } else { + self.selectedItemTags = [] + } } } From 46cb29ee2c10fe157e6dff71dea7765166370e34 Mon Sep 17 00:00:00 2001 From: Funital Date: Mon, 29 Dec 2025 20:08:10 +0900 Subject: [PATCH 17/35] =?UTF-8?q?[#43]=20=EC=98=B7=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B8=B0=EC=A4=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 17 ++++++++-- .../Presentation/View/HomeHasCodiView.swift | 31 +++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index fa98ba6e..3a4a3228 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -231,9 +231,9 @@ final class HomeDatasource { CodiItemEntity( id: 1, imageName: "image1", - clothName: "모자", - brandName: "Polo", - description: "사계절 착용 가능한 모자", + clothName: "시계", + brandName: "apple", + description: "사계절 착용 가능한 시계", x: 300, // ← 캔버스 가로의 약 25% y: 100, // ← 세로의 약 25% width: 70, @@ -274,6 +274,17 @@ final class HomeDatasource { y: 300, // ← 세로 75% width: 90, height: 90 + ), + CodiItemEntity( + id: 5, + imageName: "image5", + clothName: "??", + brandName: "??", + description: "사계절 착용 가능한 ??", + x: 200, // ← 캔버스 가로의 약 25% + y: 200, // ← 세로의 약 25% + width: 100, + height: 100 ) ] } diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index a3a54443..9d0ac93e 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -76,18 +76,37 @@ struct HomeHasCodiView: View { if let selectedID = viewModel.selectedItemID, let selectedItem = viewModel.codiItems.first(where: { $0.id == selectedID }) { + // 아이템이 캔버스의 왼쪽에 더 가까운지 오른쪽에 더 가까운지 판단 + let distanceToLeft = selectedItem.x + let distanceToRight = canvasSize.width - selectedItem.x + let shouldShowOnRight = distanceToLeft < distanceToRight + + // 태그의 기본 오프셋 + let tagOffset: CGFloat = 120 + ForEach(viewModel.selectedItemTags) { tag in + // 태그의 기본 위치 계산 + let baseX = selectedItem.x + (tag.locationX - 0.5) * selectedItem.width + let baseY = selectedItem.y + (tag.locationY - 0.5) * selectedItem.height + + // 태그를 좌우로 배치 + let tagX = baseX + (shouldShowOnRight ? tagOffset : -tagOffset) + + // 태그가 캔버스 밖으로 나가지 않도록 조정 + // 태그의 대략적인 너비를 100으로 가정 (실제 너비에 맞게 조정 필요) + let tagWidth: CGFloat = 100 + let tagHeight: CGFloat = 40 + + 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: selectedItem.x + (tag.locationX - 0.5) * selectedItem.width, - y: selectedItem.y + (tag.locationY - 0.5) * selectedItem.height - ) - .offset(x: tag.isRightSide ? 120 : -120) + .position(x: clampedX, y: clampedY) .transition(.opacity.combined(with: .scale)) - .zIndex(100) // 명시적으로 높은 zIndex 부여 + .zIndex(100) } } From b771d7c32459210eb35a4c79a17d5dbc57a52a53 Mon Sep 17 00:00:00 2001 From: Funital Date: Mon, 29 Dec 2025 22:23:47 +0900 Subject: [PATCH 18/35] =?UTF-8?q?[#43]=20=EC=98=B7=EC=9D=B4=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B2=BD=EC=9A=B0,=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 32 +++++++++---------- .../Presentation/View/HomeNoCodiView.swift | 4 ++- .../ViewModel/HomeViewModel.swift | 10 +++++- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 3a4a3228..bb371d84 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -139,22 +139,22 @@ final class HomeDatasource { await Task.sleep(500_000_000) // 0.5초 딜레이 (네트워크 시뮬레이션) let mockResponse: [ClothListResponseDTO] = [ - ClothListResponseDTO( - clothId: 1, - clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" - ), - ClothListResponseDTO( - clothId: 2, - clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" - ), - ClothListResponseDTO( - clothId: 3, - clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" - ), - ClothListResponseDTO( - clothId: 4, - clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" - ) +// ClothListResponseDTO( +// clothId: 1, +// clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" +// ), +// ClothListResponseDTO( +// clothId: 2, +// clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" +// ), +// ClothListResponseDTO( +// clothId: 3, +// clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" +// ), +// ClothListResponseDTO( +// clothId: 4, +// clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" +// ) ] print("Response Count:", mockResponse.count) diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index 53cc5198..7a904f17 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -16,7 +16,9 @@ struct HomeNoCodiView: View { categoryButtons codiClothList Spacer() - bottomButtons + if !viewModel.isAllCategoriesEmpty { + bottomButtons + } } .onAppear { viewModel.onAppear() diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index c9bb39c2..51328ab7 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -14,7 +14,7 @@ import CoreLocation final class HomeViewModel: ObservableObject { // MARK: - Properties - @Published var hasCodi: Bool = true + @Published var hasCodi: Bool = false @Published var selectedIndex: Int? = 0 @Published var showClothSelector: Bool = false @Published var titleFrame: CGRect = .zero @@ -35,6 +35,14 @@ final class HomeViewModel: ObservableObject { @Published var showLookBookSheet: Bool = false @Published var lookBookList: [LookBookBottomSheetEntity] = [] + var isAllCategoriesEmpty: Bool { + // activeCategories에 있는 각 카테고리의 아이템 개수를 모두 더함 + let totalItemCount = activeCategories.reduce(0) { sum, category in + sum + (clothItemsByCategory[category.id]?.count ?? 0) + } + return totalItemCount == 0 + } + let navigationRouter: NavigationRouter // MARK: - UseCases From 0651f76b612c76315149728c92c64e9fecc79f9f Mon Sep 17 00:00:00 2001 From: Funital Date: Mon, 29 Dec 2025 23:03:22 +0900 Subject: [PATCH 19/35] =?UTF-8?q?[#43]=20=EC=98=B7=EC=9D=B4=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B2=BD=EC=9A=B0,=20=EC=83=81=EC=9D=98=20?= =?UTF-8?q?=ED=95=98=EC=9D=98=20=EC=8B=A0=EB=B0=9C=20=EB=A1=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=84=B8=ED=8C=85=20=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 32 +++++++++---------- .../ViewModel/EditCategoryViewModel.swift | 6 ++++ .../ViewModel/HomeViewModel.swift | 23 ++++++++----- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index bb371d84..3a4a3228 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -139,22 +139,22 @@ final class HomeDatasource { await Task.sleep(500_000_000) // 0.5초 딜레이 (네트워크 시뮬레이션) let mockResponse: [ClothListResponseDTO] = [ -// ClothListResponseDTO( -// clothId: 1, -// clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" -// ), -// ClothListResponseDTO( -// clothId: 2, -// clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" -// ), -// ClothListResponseDTO( -// clothId: 3, -// clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" -// ), -// ClothListResponseDTO( -// clothId: 4, -// clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" -// ) + ClothListResponseDTO( + clothId: 1, + clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" + ), + ClothListResponseDTO( + clothId: 2, + clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" + ), + ClothListResponseDTO( + clothId: 3, + clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" + ), + ClothListResponseDTO( + clothId: 4, + clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" + ) ] print("Response Count:", mockResponse.count) diff --git a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift index 00f8a00e..2967b108 100644 --- a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift @@ -56,9 +56,15 @@ final class EditCategoryViewModel: ObservableObject { let category = defaultCategories[i] if [1, 2, 5].contains(category.id) { defaultCategories[i].itemCount = 1 + } else { + defaultCategories[i].itemCount = 0 } } self.categories = defaultCategories + // 초기 로드 시 바로 저장하여 Home에서도 동일하게 보이도록 함 + if let encoded = try? JSONEncoder().encode(defaultCategories) { + savedCategoriesData = encoded + } } self.initialCategories = self.categories } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 51328ab7..a86efe4f 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -85,8 +85,11 @@ final class HomeViewModel: ObservableObject { } func loadActiveCategories() { - let categories = categoryUseCase.loadCategories() - activeCategories = categories + let allCategories = categoryUseCase.loadCategories() + print("전체 카테고리 개수: \(allCategories.count)") + + self.activeCategories = allCategories.filter { $0.itemCount > 0 } + print("활성화된 카테고리: \(activeCategories.map { $0.title })") let clothItems = categoryUseCase.loadClothItems() clothItemsByCategory = Dictionary(grouping: clothItems) { $0.categoryId } @@ -94,13 +97,17 @@ final class HomeViewModel: ObservableObject { // MARK: - 새로운 API 방식 (비동기) func loadActiveCategoriesWithAPI() async { - let categories = categoryUseCase.loadCategories() - activeCategories = categories + // 1. 모든 카테고리를 가져온 후 itemCount가 1 이상인 것만 필터링 + let allCategories = categoryUseCase.loadCategories() + let filteredCategories = allCategories.filter { $0.itemCount > 0 } + + // 2. UI에 반영될 리스트를 필터링된 것으로 교체 + self.activeCategories = filteredCategories - // 각 카테고리별로 옷 아이템 로드 var allClothItems: [HomeClothEntity] = [] - for category in categories { + // 3. 전체(categories)가 아닌 필터링된 리스트(filteredCategories)로 루프 실행 + for category in filteredCategories { do { let items = try await categoryUseCase.loadClothItems( lastClothId: nil, @@ -110,11 +117,11 @@ final class HomeViewModel: ObservableObject { ) allClothItems.append(contentsOf: items) } catch { - print("Failed to load cloth items for category \(category.id): \(error)") + print("Failed to load items for category \(category.id): \(error)") } } - // 카테고리별로 그룹화 + // 4. 결과 그룹화 clothItemsByCategory = Dictionary(grouping: allClothItems) { $0.categoryId } } From c723d59fe91e8f9835e3f01f664940c7f8dbe0ab Mon Sep 17 00:00:00 2001 From: Funital Date: Mon, 29 Dec 2025 23:21:37 +0900 Subject: [PATCH 20/35] =?UTF-8?q?[#43]=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=84=20mock=20data=EA=B0=80=20=EB=9C=A8?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 3a4a3228..027c24a1 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -129,35 +129,37 @@ final class HomeDatasource { // MARK: - Cloth Items (API Mock) func fetchClothItems(request: ClothListRequestDTO) async throws -> [ClothListResponseDTO] { - // TODO: 실제 API 호출로 교체 - // let response = try await apiClient.get("/api/clothes", parameters: request.toQueryParameters()) - print("===== 🔵 Cloth List Request Mock =====") print("Request Parameters:", request.toQueryParameters()) - // Mock Response Data - await Task.sleep(500_000_000) // 0.5초 딜레이 (네트워크 시뮬레이션) + // 카테고리 ID에 따라 다른 데이터를 반환 + let categoryId = request.categoryId ?? 1 + let mockResponse: [ClothListResponseDTO] - let mockResponse: [ClothListResponseDTO] = [ - ClothListResponseDTO( - clothId: 1, - clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" - ), - ClothListResponseDTO( - clothId: 2, - clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" - ), - ClothListResponseDTO( - clothId: 3, - clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" - ), - ClothListResponseDTO( - clothId: 4, - clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" - ) - ] + switch categoryId { + case 1: // 상의 + mockResponse = [ + ClothListResponseDTO(clothId: 101, clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800"), // 흰 티셔츠 + ClothListResponseDTO(clothId: 102, clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800") // 셔츠 + ] + case 2: // 바지 + mockResponse = [ + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ] + case 5: // 신발 + mockResponse = [ + ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), // 빨간 운동화 + ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") // 갈색 구두 + ] + default: + // 나머지 카테고리는 빈 배열 혹은 기본 이미지 반환 + mockResponse = [ + ClothListResponseDTO(clothId: Int64(categoryId * 100), clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800") + ] + } - print("Response Count:", mockResponse.count) + print("Response Count for Category \(categoryId):", mockResponse.count) print("===== ✅ Mock response complete =====") return mockResponse From 0a876b79cfcdbdacbd44c6518d6ff97551d16e44 Mon Sep 17 00:00:00 2001 From: Funital Date: Mon, 29 Dec 2025 23:34:44 +0900 Subject: [PATCH 21/35] =?UTF-8?q?[#43]=20=EC=98=B7=EC=9D=B4=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B2=BD=EC=9A=B0=EC=9D=98=20CodiClothView=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=A0=95=EB=A0=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Features/Home/Data/DataSources/HomeDatasource.swift | 2 +- .../Features/Home/Presentation/Component/CodiClothView.swift | 4 ++++ Codive/Features/Home/Presentation/View/HomeNoCodiView.swift | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 027c24a1..f90c5226 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -155,7 +155,7 @@ final class HomeDatasource { default: // 나머지 카테고리는 빈 배열 혹은 기본 이미지 반환 mockResponse = [ - ClothListResponseDTO(clothId: Int64(categoryId * 100), clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800") +// ClothListResponseDTO(clothId: Int64(categoryId * 100), clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800") ] } diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index a44b5cd0..64bb5e33 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -261,4 +261,8 @@ struct CodiClothView: View { // 빈 상태 미리보기 (3칸 + 안내 카드) CodiClothView(title: "바지", items: [], isEmptyState: true) .padding(.horizontal, 20) + CodiClothView(title: "바지", items: [], isEmptyState: true) + .padding(.horizontal, 20) + CodiClothView(title: "바지", items: [], isEmptyState: true) + .padding(.horizontal, 20) } diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index 7a904f17..3372e266 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -55,6 +55,7 @@ struct HomeNoCodiView: View { items: clothItems, isEmptyState: clothItems.isEmpty ) + .id("\(category.id)-\(clothItems.count)") } } .padding(.horizontal, 20) From 814f10dd7bc516c7ff3ac8f7d0f9be90a5d2ea8f Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 2 Jan 2026 01:26:18 +0900 Subject: [PATCH 22/35] =?UTF-8?q?[#43]=20=EC=B4=9D=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=207=EA=B0=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EB=B6=88=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Component/CategoryCounterView.swift | 33 +++++++------ .../Presentation/View/EditCategoryView.swift | 5 +- .../ViewModel/EditCategoryViewModel.swift | 46 ++++++++++++------- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift index b807e7f9..985602fe 100644 --- a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift +++ b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift @@ -12,10 +12,14 @@ struct CategoryCounterView: View { @Binding var count: Int let totalCount: Int - let maxLimit: Int = 10 - - private var minCount: Int { - return 0 + let isFixed: Bool // 고정 여부 추가 + + private let maxLimit: Int = 7 + private let categoryLimit: Int = 1 + + /// Treats count like an empty-state flag for lint clarity + private var isEmpty: Bool { + count == .zero } var body: some View { @@ -26,42 +30,44 @@ struct CategoryCounterView: View { Spacer() + // 감소 버튼 Button { - if count > minCount { + if !isFixed && !isEmpty { count -= 1 } } label: { Circle() - .fill(count > minCount ? Color.Codive.main5 : Color.Codive.grayscale5) + .fill(!isFixed && !isEmpty ? Color.Codive.main5 : Color.Codive.grayscale5) .frame(width: 28, height: 28) .overlay( Image(systemName: "minus") .font(.system(size: 14, weight: .bold)) - .foregroundStyle(count > minCount ? Color.Codive.main1 : Color.Codive.grayscale3) + .foregroundStyle(!isFixed && !isEmpty ? Color.Codive.main1 : Color.Codive.grayscale3) ) } - .disabled(count <= minCount) + .disabled(isFixed || isEmpty) Text("\(count)") .font(Font.codive_body1_medium) .foregroundStyle(.black) .frame(width: 24) + // 증가 버튼 (카테고리당 최대 1개 & 전체 합 7개 제한) Button { - if totalCount < maxLimit { + if !isFixed && count < categoryLimit && totalCount < maxLimit { count += 1 } } label: { Circle() - .fill(totalCount < maxLimit ? Color.Codive.main5 : Color.Codive.grayscale5) + .fill(!isFixed && count < categoryLimit && totalCount < maxLimit ? Color.Codive.main5 : Color.Codive.grayscale5) .frame(width: 28, height: 28) .overlay( Image(systemName: "plus") .font(.system(size: 14, weight: .bold)) - .foregroundStyle(totalCount < maxLimit ? Color.Codive.main1 : Color.Codive.grayscale3) + .foregroundStyle(!isFixed && count < categoryLimit && totalCount < maxLimit ? Color.Codive.main1 : Color.Codive.grayscale3) ) } - .disabled(totalCount >= maxLimit) + .disabled(isFixed || count >= categoryLimit || totalCount >= maxLimit) } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -83,7 +89,8 @@ private struct PreviewWrapper: View { CategoryCounterView( title: "상의", count: $topCount, - totalCount: 1 + totalCount: 1, + isFixed: true ) .padding() } diff --git a/Codive/Features/Home/Presentation/View/EditCategoryView.swift b/Codive/Features/Home/Presentation/View/EditCategoryView.swift index 4475a95a..568b066d 100644 --- a/Codive/Features/Home/Presentation/View/EditCategoryView.swift +++ b/Codive/Features/Home/Presentation/View/EditCategoryView.swift @@ -22,7 +22,7 @@ struct EditCategoryView: View { ScrollView { VStack { - Text("\(TextLiteral.Home.currentCategoryCount) (\(viewModel.totalCount)/10)") + Text("\(TextLiteral.Home.currentCategoryCount) (\(viewModel.totalCount)/7)") .font(Font.codive_title2) .foregroundStyle(Color.Codive.grayscale1) .frame(maxWidth: .infinity, alignment: .leading) @@ -33,7 +33,8 @@ struct EditCategoryView: View { CategoryCounterView( title: category.title, count: $category.itemCount, - totalCount: viewModel.totalCount + totalCount: viewModel.totalCount, + isFixed: viewModel.isFixed(category: category) // 고정 정보 전달 ) } } diff --git a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift index 2967b108..a9699c1d 100644 --- a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift @@ -12,6 +12,12 @@ final class EditCategoryViewModel: ObservableObject { // MARK: - Properties private let navigationRouter: NavigationRouter + + // 전체 최대 개수를 7로 설정 + private let maxTotalCount = 7 + // 고정 카테고리 ID (상의: 1, 바지: 2, 신발: 5) + private let fixedCategoryIds: Set = [1, 2, 5] + var totalCount: Int { categories.reduce(0) { $0 + $1.itemCount } } var hasChanges: Bool { @@ -29,11 +35,11 @@ final class EditCategoryViewModel: ObservableObject { private var initialCategories: [CategoryEntity] = [] private static var allCategories: [CategoryEntity] = [ - CategoryEntity(id: 1, title: "상의", itemCount: 0), - CategoryEntity(id: 2, title: "바지", itemCount: 0), + CategoryEntity(id: 1, title: "상의", itemCount: 1), + CategoryEntity(id: 2, title: "바지", itemCount: 1), CategoryEntity(id: 3, title: "스커트", itemCount: 0), CategoryEntity(id: 4, title: "아우터", itemCount: 0), - CategoryEntity(id: 5, title: "신발", itemCount: 0), + CategoryEntity(id: 5, title: "신발", itemCount: 1), CategoryEntity(id: 6, title: "가방", itemCount: 0), CategoryEntity(id: 7, title: "패션 소품", itemCount: 0) ] @@ -48,36 +54,42 @@ final class EditCategoryViewModel: ObservableObject { private func loadInitialData() { if let data = savedCategoriesData, let decodedCategories = try? JSONDecoder().decode([CategoryEntity].self, from: data) { - self.categories = decodedCategories - } else { - // 앱 최초 실행 시 또는 저장된 데이터가 없을 경우 - var defaultCategories = Self.allCategories - for i in defaultCategories.indices { - let category = defaultCategories[i] - if [1, 2, 5].contains(category.id) { - defaultCategories[i].itemCount = 1 - } else { - defaultCategories[i].itemCount = 0 + // 저장된 데이터가 있더라도 고정 항목은 항상 1로 강제 유지 + self.categories = decodedCategories.map { category in + var updated = category + if fixedCategoryIds.contains(category.id) { + updated.itemCount = 1 } + return updated } - self.categories = defaultCategories - // 초기 로드 시 바로 저장하여 Home에서도 동일하게 보이도록 함 - if let encoded = try? JSONEncoder().encode(defaultCategories) { + } else { + self.categories = Self.allCategories + if let encoded = try? JSONEncoder().encode(Self.allCategories) { savedCategoriesData = encoded } } self.initialCategories = self.categories } + // 고정 여부 확인 함수 + func isFixed(category: CategoryEntity) -> Bool { + return fixedCategoryIds.contains(category.id) + } + // MARK: - Category Count Handling func incrementCount(for category: CategoryEntity) { + // 고정 항목이거나, 이미 1개이거나, 전체 합이 7이면 중단 + guard !isFixed(category: category) else { return } guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - if totalCount < 10 { + + if categories[index].itemCount < 1 && totalCount < maxTotalCount { categories[index].itemCount += 1 } } func decrementCount(for category: CategoryEntity) { + // 고정 항목이면 감소 불가 + guard !isFixed(category: category) else { return } guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } if categories[index].itemCount > 0 { categories[index].itemCount -= 1 From 6b6db95098d727e1a803816c2bb9ed65c6969081 Mon Sep 17 00:00:00 2001 From: Funital Date: Sat, 3 Jan 2026 01:11:58 +0900 Subject: [PATCH 23/35] =?UTF-8?q?[#43]=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=83=81=ED=92=88=20=EA=B0=9C=EC=88=98=20=EB=B3=84?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EC=9E=90=EB=8F=99=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 40 +++-- .../Component/CompletePopUp.swift | 160 ++++++++++-------- .../Home/Presentation/View/HomeView.swift | 5 +- .../ViewModel/HomeViewModel.swift | 11 ++ 4 files changed, 130 insertions(+), 86 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index f90c5226..d3a30b3d 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -142,16 +142,36 @@ final class HomeDatasource { ClothListResponseDTO(clothId: 101, clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800"), // 흰 티셔츠 ClothListResponseDTO(clothId: 102, clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800") // 셔츠 ] - case 2: // 바지 - mockResponse = [ - ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 - ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 - ] - case 5: // 신발 - mockResponse = [ - ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), // 빨간 운동화 - ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") // 갈색 구두 - ] +// case 2: // 바지 +// mockResponse = [ +// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 +// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 +// ] +// case 3: // 바지 +// mockResponse = [ +// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 +// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 +// ] +// case 4: // 바지 +// mockResponse = [ +// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 +// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 +// ] +// case 5: // 신발 +// mockResponse = [ +// ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), // 빨간 운동화 +// ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") // 갈색 구두 +// ] +// case 6: // 바지 +// mockResponse = [ +// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 +// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 +// ] +// case 7: // 바지 +// mockResponse = [ +// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 +// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 +// ] default: // 나머지 카테고리는 빈 배열 혹은 기본 이미지 반환 mockResponse = [ diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift index ae74c2f8..0bceab42 100644 --- a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -8,109 +8,121 @@ import SwiftUI struct CompletePopUp: View { - /// 팝업 표시 여부를 제어하는 바인딩 @Binding var isPresented: Bool - - /// 기록하기 버튼 액션 var onRecordTapped: () -> Void - /// 닫기 버튼 액션 var onCloseTapped: () -> Void - /// 코디 이미지 URL - var imageURL: String? + + // 수정: 단일 URL 대신 선택된 옷 리스트를 받음 + var selectedClothes: [HomeClothEntity] var body: some View { ZStack { - Color.black - .opacity(0.7) - .ignoresSafeArea() - .onTapGesture { - isPresented = false - } + Color.black.opacity(0.7).ignoresSafeArea() + .onTapGesture { isPresented = false } VStack { - Spacer(minLength: 32) - popupCard .padding(.horizontal, 32) - .aspectRatio(311 / 360, contentMode: .fit) - - Spacer(minLength: 32) } } } private var popupCard: some View { VStack(spacing: 6) { - Text(TextLiteral.Home.popUpTitle) - .font(.codive_title1) - .foregroundColor(Color.Codive.grayscale1) - .padding(.top, 32) - - Text(TextLiteral.Home.popUpSubtitle) - .font(.codive_body2_regular) - .foregroundColor(Color.Codive.grayscale3) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) + Text(TextLiteral.Home.popUpTitle).font(.codive_title1).padding(.top, 32) + Text(TextLiteral.Home.popUpSubtitle).font(.codive_body2_regular).padding(.horizontal, 24) - AsyncImage(url: URL(string: imageURL ?? "")) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 204, height: 204) - - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: 204, height: 204) - .clipped() - - case .failure: - Image(systemName: "photo") - .resizable() - .scaledToFill() - .foregroundColor(.gray.opacity(0.4)) - .frame(width: 204, height: 204) - .clipped() - - @unknown default: - EmptyView() - } - } - .padding(.vertical, 16) + // 핵심 수정 부분: 이미지 합성 뷰 + CodiCompositeView(clothes: selectedClothes) + .frame(width: 204, height: 204) + .padding(.vertical, 16) HStack(spacing: 9) { - CustomButton( - text: TextLiteral.Home.close, - widthType: .half, - styleType: .border - ) { + CustomButton(text: TextLiteral.Home.close, widthType: .half, styleType: .border) { isPresented = false onCloseTapped() } - - CustomButton( - text: TextLiteral.Home.record, - widthType: .half - ) { + CustomButton(text: TextLiteral.Home.record, widthType: .half) { onRecordTapped() } } .padding(.horizontal, 20) .padding(.bottom, 24) } - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color.white) - ) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white)) } } -#Preview { - CompletePopUp( - isPresented: .constant(true), - onRecordTapped: {}, - onCloseTapped: {}, - imageURL: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" - ) +// MARK: - 합성 레이아웃 뷰 +struct CodiCompositeView: View { + let clothes: [HomeClothEntity] + let containerSize: CGFloat = 204 + let itemSize: CGFloat = 102 // 50% 사이즈 (204 / 2) - 필요에 따라 조절 + + var body: some View { + ZStack { + // 투명 정사각형 배경 + Rectangle() + .fill(Color.clear) + .frame(width: containerSize, height: containerSize) + + // Case 별 레이아웃 처리 + ForEach(0.. CGPoint { + let center = containerSize / 2 + let quarter = containerSize / 4 + let eighth = containerSize / 8 + let spacing = containerSize / 3 // 3열 배치용 간격 + + switch totalCount { + case 1: // Case 1: 정중앙 + return CGPoint(x: center, y: center) + + case 2: // Case 2: HStack 중앙 + return index == 0 ? CGPoint(x: quarter, y: center) : CGPoint(x: quarter * 3, y: center) + + case 3: // Case 3: VStack 1열 중앙 + let yPos = [quarter, center, quarter * 3] + return CGPoint(x: center, y: yPos[index]) + + case 4...7: // Case 4-7: 왼쪽/오른쪽 열 배치 + let leftCount = totalCount == 7 ? 4 : 3 + let isLeftColumn = index < leftCount + let xPos = isLeftColumn ? quarter : quarter * 3 + + // 각 열 내부에서의 인덱스 + let internalIndex = isLeftColumn ? index : index - leftCount + + // y축 계산 (왼쪽 상단 기준 정렬) + let rowSpacing = containerSize / CGFloat(max(leftCount, 1) + 1) + let yPos = rowSpacing * CGFloat(internalIndex + 1) + + return CGPoint(x: xPos, y: yPos) + + default: + return CGPoint(x: center, y: center) + } + } } + +//#Preview { +// CompletePopUp( +// isPresented: .constant(true), +// onRecordTapped: {}, +// onCloseTapped: {}, +// imageURL: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" +// ) +//} diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index d9ee292b..ea11adcf 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -57,17 +57,18 @@ struct HomeView: View { HomeNoCodiView(viewModel: viewModel) } } - .padding(.bottom, 16) // 필요하면 살짝만 여백 + .padding(.bottom, 16) } .id(scrollViewID) // 팝업 오버레이 if viewModel.showCompletePopUp { + // 수정된 부분: imageURL 대신 selectedClothes 리스트를 전달합니다. CompletePopUp( isPresented: $viewModel.showCompletePopUp, onRecordTapped: viewModel.handlePopupRecord, onCloseTapped: viewModel.handlePopupClose, - imageURL: viewModel.completedCodiImageURL + selectedClothes: viewModel.selectedCodiClothes ) .zIndex(1) } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index a86efe4f..7628088b 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -26,6 +26,7 @@ final class HomeViewModel: ObservableObject { @Published var selectedItemTags: [ClothTagEntity] = [] @Published var activeCategories: [CategoryEntity] = [] @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] + @Published var selectedCodiClothes: [HomeClothEntity] = [] // 팝업 관련 프로퍼티 추가 @Published var showCompletePopUp: Bool = false @@ -231,6 +232,15 @@ final class HomeViewModel: ObservableObject { } func handleConfirmCodiTap() { + // 1. 현재 화면에 노출된 각 카테고리의 첫 번째 아이템들을 수집 (ID 순 정렬) + let items = activeCategories + .sorted(by: { $0.id < $1.id }) + .compactMap { clothItemsByCategory[$0.id]?.first } + + self.selectedCodiClothes = items + + // 2. 팝업 띄우기 + self.showCompletePopUp = true } func handleEditCategory() { @@ -247,6 +257,7 @@ final class HomeViewModel: ObservableObject { showCompletePopUp = false // 기록하기 로직 구현 // 예: navigationRouter.navigate(to: .recordCodi) + self.hasCodi = true } func handlePopupClose() { From fc69dcc5c6649c139077496cea7739431ad07fba Mon Sep 17 00:00:00 2001 From: Funital Date: Sun, 4 Jan 2026 00:01:45 +0900 Subject: [PATCH 24/35] =?UTF-8?q?[#43]=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=EC=84=A0=ED=83=9D=EB=90=9C=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=EC=9D=98=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EB=9D=84=EC=9A=B0=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 60 +++++++++---------- .../Component/CodiClothView.swift | 10 +++- .../Presentation/View/HomeNoCodiView.swift | 6 +- .../ViewModel/HomeViewModel.swift | 23 +++++-- 4 files changed, 63 insertions(+), 36 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index d3a30b3d..a4cf5800 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -142,36 +142,36 @@ final class HomeDatasource { ClothListResponseDTO(clothId: 101, clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800"), // 흰 티셔츠 ClothListResponseDTO(clothId: 102, clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800") // 셔츠 ] -// case 2: // 바지 -// mockResponse = [ -// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 -// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 -// ] -// case 3: // 바지 -// mockResponse = [ -// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 -// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 -// ] -// case 4: // 바지 -// mockResponse = [ -// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 -// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 -// ] -// case 5: // 신발 -// mockResponse = [ -// ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), // 빨간 운동화 -// ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") // 갈색 구두 -// ] -// case 6: // 바지 -// mockResponse = [ -// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 -// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 -// ] -// case 7: // 바지 -// mockResponse = [ -// ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 -// ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 -// ] + case 2: // 바지 + mockResponse = [ + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ] + case 3: // 바지 + mockResponse = [ + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ] + case 4: // 바지 + mockResponse = [ + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ] + case 5: // 신발 + mockResponse = [ + ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), // 빨간 운동화 + ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") // 갈색 구두 + ] + case 6: // 바지 + mockResponse = [ + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ] + case 7: // 바지 + mockResponse = [ + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ] default: // 나머지 카테고리는 빈 배열 혹은 기본 이미지 반환 mockResponse = [ diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index 64bb5e33..450bc5c5 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -203,6 +203,8 @@ struct CodiClothView: View { let title: String let items: [HomeClothEntity] let isEmptyState: Bool + // 추가: 인덱스가 변경되었을 때 실행될 클로저 + var onIndexChanged: ((Int) -> Void)? private let spacing: CGFloat = 10 private let activeScale: CGFloat = 1.0 @@ -212,11 +214,13 @@ struct CodiClothView: View { init(title: String, items: [HomeClothEntity] = [], - isEmptyState: Bool) { + isEmptyState: Bool, + onIndexChanged: ((Int) -> Void)? = nil) { // 초기화 수정 self.title = title self.items = items self.isEmptyState = isEmptyState + self.onIndexChanged = onIndexChanged let initialIndex = isEmptyState ? 1 : max(0, items.count / 2) _currentIndex = State(initialValue: initialIndex) @@ -233,6 +237,10 @@ struct CodiClothView: View { inactiveScale: inactiveScale, isEmptyState: isEmptyState ) + // 인덱스가 바뀔 때마다 외부로 알려줌 + .onChange(of: currentIndex) { newValue in + onIndexChanged?(newValue) + } Text(title) .font(.caption.bold()) diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index 3372e266..0e1e2dfe 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -53,7 +53,11 @@ struct HomeNoCodiView: View { CodiClothView( title: category.title, items: clothItems, - isEmptyState: clothItems.isEmpty + isEmptyState: clothItems.isEmpty, + onIndexChanged: { newIndex in + // 핵심: 사용자가 스크롤 할 때마다 ViewModel의 딕셔너리 업데이트 + viewModel.updateSelectedIndex(for: category.id, index: newIndex) + } ) .id("\(category.id)-\(clothItems.count)") } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 7628088b..ce48dae2 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -27,6 +27,7 @@ final class HomeViewModel: ObservableObject { @Published var activeCategories: [CategoryEntity] = [] @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] @Published var selectedCodiClothes: [HomeClothEntity] = [] + @Published var selectedIndicesByCategory: [Int: Int] = [:] // 팝업 관련 프로퍼티 추가 @Published var showCompletePopUp: Bool = false @@ -222,6 +223,10 @@ final class HomeViewModel: ObservableObject { } } + func updateSelectedIndex(for categoryId: Int, index: Int) { + selectedIndicesByCategory[categoryId] = index + } + func handleSearchTap() {} func handleNotificationTap() {} @@ -232,14 +237,24 @@ final class HomeViewModel: ObservableObject { } func handleConfirmCodiTap() { - // 1. 현재 화면에 노출된 각 카테고리의 첫 번째 아이템들을 수집 (ID 순 정렬) + // 수정된 수집 로직: 저장된 인덱스를 기반으로 아이템 추출 let items = activeCategories .sorted(by: { $0.id < $1.id }) - .compactMap { clothItemsByCategory[$0.id]?.first } + .compactMap { category -> HomeClothEntity? in + guard let clothList = clothItemsByCategory[category.id] else { return nil } + + // 해당 카테고리에 저장된 인덱스가 있으면 사용, 없으면 0번째 사용 + let selectedIndex = selectedIndicesByCategory[category.id] ?? 0 + + // 배열 범위를 벗어나지 않도록 방어 코드 추가 + if clothList.indices.contains(selectedIndex) { + return clothList[selectedIndex] + } else { + return clothList.first + } + } self.selectedCodiClothes = items - - // 2. 팝업 띄우기 self.showCompletePopUp = true } From 8307c9b3b53eeee94e395348236bbf43495af9b5 Mon Sep 17 00:00:00 2001 From: Funital Date: Sun, 4 Jan 2026 00:16:35 +0900 Subject: [PATCH 25/35] =?UTF-8?q?[#43]=20=EA=B3=A0=EC=A0=95=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=83=81=ED=92=88=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Component/CategoryCounterView.swift | 6 ++-- .../ViewModel/EditCategoryViewModel.swift | 32 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift index 985602fe..cc842074 100644 --- a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift +++ b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift @@ -12,7 +12,7 @@ struct CategoryCounterView: View { @Binding var count: Int let totalCount: Int - let isFixed: Bool // 고정 여부 추가 + let isFixed: Bool private let maxLimit: Int = 7 private let categoryLimit: Int = 1 @@ -30,7 +30,7 @@ struct CategoryCounterView: View { Spacer() - // 감소 버튼 + // 감소 버튼: 이제 상의/하의 등도 isEmpty가 아니면(1이면) 0으로 줄일 수 있습니다. Button { if !isFixed && !isEmpty { count -= 1 @@ -52,7 +52,7 @@ struct CategoryCounterView: View { .foregroundStyle(.black) .frame(width: 24) - // 증가 버튼 (카테고리당 최대 1개 & 전체 합 7개 제한) + // 증가 버튼 Button { if !isFixed && count < categoryLimit && totalCount < maxLimit { count += 1 diff --git a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift index a9699c1d..c08f0508 100644 --- a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift @@ -16,7 +16,7 @@ final class EditCategoryViewModel: ObservableObject { // 전체 최대 개수를 7로 설정 private let maxTotalCount = 7 // 고정 카테고리 ID (상의: 1, 바지: 2, 신발: 5) - private let fixedCategoryIds: Set = [1, 2, 5] + private let fixedCategoryIds: Set = [] var totalCount: Int { categories.reduce(0) { $0 + $1.itemCount } } @@ -78,23 +78,23 @@ final class EditCategoryViewModel: ObservableObject { // MARK: - Category Count Handling func incrementCount(for category: CategoryEntity) { - // 고정 항목이거나, 이미 1개이거나, 전체 합이 7이면 중단 - guard !isFixed(category: category) else { return } - guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - - if categories[index].itemCount < 1 && totalCount < maxTotalCount { - categories[index].itemCount += 1 + // 고정 제약이 없으므로 더 자유롭게 증가 가능 + guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } + + // 개별 카테고리 최대 1개 & 전체 합 7개 미만일 때만 증가 + if categories[index].itemCount < 1 && totalCount < maxTotalCount { + categories[index].itemCount += 1 + } } - } - - func decrementCount(for category: CategoryEntity) { - // 고정 항목이면 감소 불가 - guard !isFixed(category: category) else { return } - guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - if categories[index].itemCount > 0 { - categories[index].itemCount -= 1 + + func decrementCount(for category: CategoryEntity) { + guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } + + // 0보다 클 때만 감소 가능 + if categories[index].itemCount > 0 { + categories[index].itemCount -= 1 + } } - } // MARK: - Reset func resetCounts() { From 307c8ce2928376d1a8c1ae84b1fe8292f35a973a Mon Sep 17 00:00:00 2001 From: Funital Date: Sun, 4 Jan 2026 00:42:01 +0900 Subject: [PATCH 26/35] =?UTF-8?q?[#43]=20=ED=8C=9D=EC=97=85=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Component/CompletePopUp.swift | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift index 0bceab42..bf51f554 100644 --- a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -14,29 +14,29 @@ struct CompletePopUp: View { // 수정: 단일 URL 대신 선택된 옷 리스트를 받음 var selectedClothes: [HomeClothEntity] - + var body: some View { ZStack { Color.black.opacity(0.7).ignoresSafeArea() .onTapGesture { isPresented = false } - + VStack { popupCard .padding(.horizontal, 32) } } } - + private var popupCard: some 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: 204, height: 204) .padding(.vertical, 16) - + HStack(spacing: 9) { CustomButton(text: TextLiteral.Home.close, widthType: .half, styleType: .border) { isPresented = false @@ -57,61 +57,83 @@ struct CompletePopUp: View { struct CodiCompositeView: View { let clothes: [HomeClothEntity] let containerSize: CGFloat = 204 - let itemSize: CGFloat = 102 // 50% 사이즈 (204 / 2) - 필요에 따라 조절 - + let itemSize: CGFloat = 100 // 100*100 비율 + var body: some View { ZStack { - // 투명 정사각형 배경 + // 배경 영역 Rectangle() .fill(Color.clear) .frame(width: containerSize, height: containerSize) - - // Case 별 레이아웃 처리 + + // 아이템 배치 ForEach(0.. CGPoint { let center = containerSize / 2 - let quarter = containerSize / 4 - let eighth = containerSize / 8 - let spacing = containerSize / 3 // 3열 배치용 간격 - + + let stepFor3 = itemSize - 25 // 세로 25 겹침 (간격 75) + let stepFor4 = itemSize - 36 // 세로 36 겹침 (간격 64) + let horizontalOverlap: CGFloat = 12 + let horizontalStep = itemSize - horizontalOverlap // 수평 12 겹침 (간격 88) + switch totalCount { - case 1: // Case 1: 정중앙 + case 1: return CGPoint(x: center, y: center) - case 2: // Case 2: HStack 중앙 - return index == 0 ? CGPoint(x: quarter, y: center) : CGPoint(x: quarter * 3, y: center) + case 2: + let totalW = itemSize + stepFor3 + let startX = (containerSize - totalW) / 2 + (itemSize / 2) + return CGPoint(x: startX + (CGFloat(index) * stepFor3), y: center) - case 3: // Case 3: VStack 1열 중앙 - let yPos = [quarter, center, quarter * 3] - return CGPoint(x: center, y: yPos[index]) + case 3: + let totalH = itemSize + (stepFor3 * 2) + let startY = (containerSize - totalH) / 2 + (itemSize / 2) + return CGPoint(x: center, y: startY + (CGFloat(index) * stepFor3)) - case 4...7: // Case 4-7: 왼쪽/오른쪽 열 배치 - let leftCount = totalCount == 7 ? 4 : 3 - let isLeftColumn = index < leftCount - let xPos = isLeftColumn ? quarter : quarter * 3 + case 4...7: + // 열 구분 (7개일 때만 왼쪽이 4개, 그 외에는 왼쪽 3개 배치) + let leftCount = (totalCount == 7) ? 4 : 3 + let rightCount = totalCount - leftCount - // 각 열 내부에서의 인덱스 + // 1. 세로 시작점(startY) 계산: 왼쪽 열 기준 중앙 정렬 + let leftStep = (leftCount == 4) ? stepFor4 : stepFor3 + let leftTotalH = itemSize + (leftStep * CGFloat(leftCount - 1)) + let startY = (containerSize - leftTotalH) / 2 + (itemSize / 2) + + // 2. 가로 위치(xPos) 계산: 두 열 사이 12pt 겹침 적용 + // 두 열의 총 너비 = itemSize + horizontalStep (88) = 188 + let totalW = itemSize + horizontalStep + let startX = (containerSize - totalW) / 2 + (itemSize / 2) + + let isLeftColumn = index < leftCount let internalIndex = isLeftColumn ? index : index - leftCount - // y축 계산 (왼쪽 상단 기준 정렬) - let rowSpacing = containerSize / CGFloat(max(leftCount, 1) + 1) - let yPos = rowSpacing * CGFloat(internalIndex + 1) + // 왼쪽 열은 startX, 오른쪽 열은 startX + 88 + let xPos = isLeftColumn ? startX : startX + horizontalStep + + // 3. 세로 위치(yPos) 계산 + let currentColumnTotal = isLeftColumn ? leftCount : rightCount + let step = (currentColumnTotal == 4) ? stepFor4 : stepFor3 + + return CGPoint(x: xPos, y: startY + (CGFloat(internalIndex) * step)) - return CGPoint(x: xPos, y: yPos) - default: return CGPoint(x: center, y: center) } From 3e8e184bbf8958ebde664bbc9c112d8f5252088c Mon Sep 17 00:00:00 2001 From: Funital Date: Sun, 4 Jan 2026 00:57:08 +0900 Subject: [PATCH 27/35] =?UTF-8?q?[#43]=20=ED=8C=9D=EC=97=85=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Component/CompletePopUp.swift | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift index bf51f554..83553a40 100644 --- a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -34,7 +34,7 @@ struct CompletePopUp: View { // 핵심 수정 부분: 이미지 합성 뷰 CodiCompositeView(clothes: selectedClothes) - .frame(width: 204, height: 204) + .frame(width: 260, height: 260) .padding(.vertical, 16) HStack(spacing: 9) { @@ -56,15 +56,17 @@ struct CompletePopUp: View { // MARK: - 합성 레이아웃 뷰 struct CodiCompositeView: View { let clothes: [HomeClothEntity] - let containerSize: CGFloat = 204 - let itemSize: CGFloat = 100 // 100*100 비율 + // 204 -> 260으로 변경 (아이템 3~4개 수직 배치 시 약 250pt 필요) + let containerSize: CGFloat = 260 + let itemSize: CGFloat = 100 var body: some View { ZStack { - // 배경 영역 + // 배경 영역 (검정색 사각형이 이제 260 사이즈를 가집니다) Rectangle() .fill(Color.clear) .frame(width: containerSize, height: containerSize) + .cornerRadius(12) // 모서리를 살짝 깎으면 더 부드럽습니다 // 아이템 배치 ForEach(0.. CGPoint { @@ -139,12 +144,3 @@ struct CodiCompositeView: View { } } } - -//#Preview { -// CompletePopUp( -// isPresented: .constant(true), -// onRecordTapped: {}, -// onCloseTapped: {}, -// imageURL: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" -// ) -//} From 115479c5d25334b699cae7d2f2b37e3233ac8033 Mon Sep 17 00:00:00 2001 From: Funital Date: Thu, 15 Jan 2026 23:59:18 +0900 Subject: [PATCH 28/35] =?UTF-8?q?[#43]=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=BD=94=EB=94=94=20=EC=83=9D=EC=84=B1=20api=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 19 +++ .../Repositories/HomeRepositoryImpl.swift | 4 + .../Home/Domain/Entities/HomeEntity.swift | 14 +++ .../Domain/Protocols/HomeRepository.swift | 2 + .../Domain/UseCases/TodayCodiUseCase.swift | 4 + .../Component/CodiLayoutCalculator.swift | 73 ++++++++++++ .../Component/CompletePopUp.swift | 112 +++++++++--------- .../ViewModel/HomeViewModel.swift | 34 +++++- 8 files changed, 204 insertions(+), 58 deletions(-) create mode 100644 Codive/Features/Home/Presentation/Component/CodiLayoutCalculator.swift diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index a4cf5800..8c3f7d4f 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -331,4 +331,23 @@ final class HomeDatasource { LookBookBottomSheetEntity(lookbookId: 3, codiId: 103, imageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800", title: "데이트룩", count: 16) ] } + + 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 + ) + } + ) + + try await saveCodiCoordinate(requestDTO) + } } diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index 333fbf8e..16a279b1 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -65,4 +65,8 @@ final class HomeRepositoryImpl: HomeRepository { func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] { return try await dataSource.fetchLookBookList() } + + func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws { + try await dataSource.createTodayDailyCodi(codi) + } } diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 94fa55ee..e02a91a8 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -151,3 +151,17 @@ struct ClothTagEntity: Identifiable, Hashable { // 추가: 이미지 위치에 따른 방향 계산을 위한 프로퍼티 var isRightSide: Bool = true } + +struct TodayDailyCodi { + let coordinateImageUrl: String + let payloads: [CodiPayload] +} + +struct CodiPayload { + let clothId: Int + let locationX: Double + let locationY: Double + let ratio: Double + let degree: Int + let order: Int +} diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 5a667210..3777edd4 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -30,4 +30,6 @@ protocol HomeRepository { func getToday() -> DateEntity func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] + + func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws } diff --git a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift index 11b3154f..cea14a70 100644 --- a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift @@ -16,4 +16,8 @@ final class TodayCodiUseCase { func loadTodaysCodi() -> [CodiItemEntity] { return repository.fetchCodiItems() } + + func recordTodayCodi(_ codi: TodayDailyCodi) async throws { + try await repository.createTodayDailyCodi(codi) + } } diff --git a/Codive/Features/Home/Presentation/Component/CodiLayoutCalculator.swift b/Codive/Features/Home/Presentation/Component/CodiLayoutCalculator.swift new file mode 100644 index 00000000..f0557781 --- /dev/null +++ b/Codive/Features/Home/Presentation/Component/CodiLayoutCalculator.swift @@ -0,0 +1,73 @@ +// +// CodiLayoutCalculator.swift +// Codive +// +// Created by 한금준 on 1/15/26. +// + +import CoreGraphics + +struct CodiLayoutCalculator { + + static func position( + index: Int, + totalCount: Int, + containerSize: CGFloat, + itemSize: CGFloat = 100 + ) -> CGPoint { + + let center = containerSize / 2 + + let stepFor3 = itemSize - 25 + let stepFor4 = itemSize - 36 + let horizontalOverlap: CGFloat = 12 + let horizontalStep = itemSize - horizontalOverlap + + switch totalCount { + case 1: + return CGPoint(x: center, y: center) + + case 2: + let totalW = itemSize + stepFor3 + let startX = (containerSize - totalW) / 2 + (itemSize / 2) + return CGPoint( + x: startX + (CGFloat(index) * stepFor3), + y: center + ) + + case 3: + let totalH = itemSize + (stepFor3 * 2) + let startY = (containerSize - totalH) / 2 + (itemSize / 2) + return CGPoint( + x: center, + y: startY + (CGFloat(index) * stepFor3) + ) + + case 4...7: + let leftCount = (totalCount == 7) ? 4 : 3 + let rightCount = totalCount - leftCount + + let leftStep = (leftCount == 4) ? stepFor4 : stepFor3 + let leftTotalH = itemSize + (leftStep * CGFloat(leftCount - 1)) + let startY = (containerSize - leftTotalH) / 2 + (itemSize / 2) + + let totalW = itemSize + horizontalStep + let startX = (containerSize - totalW) / 2 + (itemSize / 2) + + let isLeftColumn = index < leftCount + let internalIndex = isLeftColumn ? index : index - leftCount + let xPos = isLeftColumn ? startX : startX + horizontalStep + + let currentColumnTotal = isLeftColumn ? leftCount : rightCount + let step = (currentColumnTotal == 4) ? stepFor4 : stepFor3 + + return CGPoint( + x: xPos, + y: startY + (CGFloat(internalIndex) * step) + ) + + default: + return CGPoint(x: center, y: center) + } + } +} diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift index 83553a40..d7f15dce 100644 --- a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -70,7 +70,11 @@ struct CodiCompositeView: View { // 아이템 배치 ForEach(0.. CGPoint { - let center = containerSize / 2 - - let stepFor3 = itemSize - 25 // 세로 25 겹침 (간격 75) - let stepFor4 = itemSize - 36 // 세로 36 겹침 (간격 64) - let horizontalOverlap: CGFloat = 12 - let horizontalStep = itemSize - horizontalOverlap // 수평 12 겹침 (간격 88) - - switch totalCount { - case 1: - return CGPoint(x: center, y: center) - - case 2: - let totalW = itemSize + stepFor3 - let startX = (containerSize - totalW) / 2 + (itemSize / 2) - return CGPoint(x: startX + (CGFloat(index) * stepFor3), y: center) - - case 3: - let totalH = itemSize + (stepFor3 * 2) - let startY = (containerSize - totalH) / 2 + (itemSize / 2) - return CGPoint(x: center, y: startY + (CGFloat(index) * stepFor3)) - - case 4...7: - // 열 구분 (7개일 때만 왼쪽이 4개, 그 외에는 왼쪽 3개 배치) - let leftCount = (totalCount == 7) ? 4 : 3 - let rightCount = totalCount - leftCount - - // 1. 세로 시작점(startY) 계산: 왼쪽 열 기준 중앙 정렬 - let leftStep = (leftCount == 4) ? stepFor4 : stepFor3 - let leftTotalH = itemSize + (leftStep * CGFloat(leftCount - 1)) - let startY = (containerSize - leftTotalH) / 2 + (itemSize / 2) - - // 2. 가로 위치(xPos) 계산: 두 열 사이 12pt 겹침 적용 - // 두 열의 총 너비 = itemSize + horizontalStep (88) = 188 - let totalW = itemSize + horizontalStep - let startX = (containerSize - totalW) / 2 + (itemSize / 2) - - let isLeftColumn = index < leftCount - let internalIndex = isLeftColumn ? index : index - leftCount - - // 왼쪽 열은 startX, 오른쪽 열은 startX + 88 - let xPos = isLeftColumn ? startX : startX + horizontalStep - - // 3. 세로 위치(yPos) 계산 - let currentColumnTotal = isLeftColumn ? leftCount : rightCount - let step = (currentColumnTotal == 4) ? stepFor4 : stepFor3 - - return CGPoint(x: xPos, y: startY + (CGFloat(internalIndex) * step)) - - default: - return CGPoint(x: center, y: center) - } - } +// private func calculatePosition(for index: Int, totalCount: Int) -> CGPoint { +// let center = containerSize / 2 +// +// let stepFor3 = itemSize - 25 // 세로 25 겹침 (간격 75) +// let stepFor4 = itemSize - 36 // 세로 36 겹침 (간격 64) +// let horizontalOverlap: CGFloat = 12 +// let horizontalStep = itemSize - horizontalOverlap // 수평 12 겹침 (간격 88) +// +// switch totalCount { +// case 1: +// return CGPoint(x: center, y: center) +// +// case 2: +// let totalW = itemSize + stepFor3 +// let startX = (containerSize - totalW) / 2 + (itemSize / 2) +// return CGPoint(x: startX + (CGFloat(index) * stepFor3), y: center) +// +// case 3: +// let totalH = itemSize + (stepFor3 * 2) +// let startY = (containerSize - totalH) / 2 + (itemSize / 2) +// return CGPoint(x: center, y: startY + (CGFloat(index) * stepFor3)) +// +// case 4...7: +// // 열 구분 (7개일 때만 왼쪽이 4개, 그 외에는 왼쪽 3개 배치) +// let leftCount = (totalCount == 7) ? 4 : 3 +// let rightCount = totalCount - leftCount +// +// // 1. 세로 시작점(startY) 계산: 왼쪽 열 기준 중앙 정렬 +// let leftStep = (leftCount == 4) ? stepFor4 : stepFor3 +// let leftTotalH = itemSize + (leftStep * CGFloat(leftCount - 1)) +// let startY = (containerSize - leftTotalH) / 2 + (itemSize / 2) +// +// // 2. 가로 위치(xPos) 계산: 두 열 사이 12pt 겹침 적용 +// // 두 열의 총 너비 = itemSize + horizontalStep (88) = 188 +// let totalW = itemSize + horizontalStep +// let startX = (containerSize - totalW) / 2 + (itemSize / 2) +// +// let isLeftColumn = index < leftCount +// let internalIndex = isLeftColumn ? index : index - leftCount +// +// // 왼쪽 열은 startX, 오른쪽 열은 startX + 88 +// let xPos = isLeftColumn ? startX : startX + horizontalStep +// +// // 3. 세로 위치(yPos) 계산 +// let currentColumnTotal = isLeftColumn ? leftCount : rightCount +// let step = (currentColumnTotal == 4) ? stepFor4 : stepFor3 +// +// return CGPoint(x: xPos, y: startY + (CGFloat(internalIndex) * step)) +// +// default: +// return CGPoint(x: center, y: center) +// } +// } } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index ce48dae2..7cfb2b32 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -269,10 +269,36 @@ final class HomeViewModel: ObservableObject { } func handlePopupRecord() { - showCompletePopUp = false - // 기록하기 로직 구현 - // 예: navigationRouter.navigate(to: .recordCodi) - self.hasCodi = true + let containerSize: CGFloat = 260 + + let payloads = selectedCodiClothes.enumerated().map { index, cloth in + + let position = CodiLayoutCalculator.position( + index: index, + totalCount: selectedCodiClothes.count, + containerSize: containerSize + ) + + return CodiPayload( + clothId: cloth.id, + locationX: position.x, + locationY: position.y, + ratio: 1.0, + degree: 0, + order: index + ) + } + + let todayCodi = TodayDailyCodi( + coordinateImageUrl: completedCodiImageURL ?? "", + payloads: payloads + ) + + Task { + try await todayCodiUseCase.recordTodayCodi(todayCodi) + showCompletePopUp = false + hasCodi = true + } } func handlePopupClose() { From 0e47d6e932fa767129759579d3df19143e2d6205 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 02:59:54 +0900 Subject: [PATCH 29/35] =?UTF-8?q?[#43]=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Data/DataSources/HomeDatasource.swift | 254 ++++++---------- .../Repositories/HomeRepositoryImpl.swift | 44 ++- .../Home/Domain/Entities/HomeEntity.swift | 11 +- .../Domain/Protocols/HomeRepository.swift | 26 +- .../UseCases/AddToLookBookUseCase.swift | 1 + .../Domain/UseCases/CategoryUseCase.swift | 5 - .../Domain/UseCases/CodiBoardUseCase.swift | 9 +- .../Domain/UseCases/TodayCodiUseCase.swift | 4 +- .../Component/AddBottomSheet.swift | 71 +---- .../Component/CategoryCounterView.swift | 18 -- .../Presentation/Component/CodiButton.swift | 8 - .../Component/CodiClothView.swift | 12 - .../Component/CompletePopUp.swift | 54 ---- .../Presentation/View/CodiBoardView.swift | 146 +++++---- .../Presentation/View/EditCategoryView.swift | 131 +++++--- .../Presentation/View/HomeHasCodiView.swift | 168 ++++++----- .../Presentation/View/HomeNoCodiView.swift | 92 ++++-- .../ViewModel/CodiBoardViewModel.swift | 56 ++-- .../ViewModel/EditCategoryViewModel.swift | 126 +++++--- .../ViewModel/HomeViewModel.swift | 281 ++++++++---------- 20 files changed, 718 insertions(+), 799 deletions(-) diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 8c3f7d4f..59f830fa 100644 --- a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift +++ b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift @@ -22,7 +22,9 @@ final class HomeDatasource { self.locationService = locationService } - // MARK: - Location & Geocoding + // MARK: - 날씨 및 위치 + + // 위치 private func geocodeLocation(_ location: CLLocation) async -> String { let geocoder = CLGeocoder() do { @@ -64,8 +66,8 @@ final class HomeDatasource { return "위치 정보 오류" } } - - // MARK: - Weather Data Fetching + + // 날씨 func fetchWeatherData(for location: CLLocation?) async throws -> WeatherData { let targetLocation: CLLocation @@ -101,136 +103,108 @@ final class HomeDatasource { return weatherData } - // MARK: - Categories - func loadCategories() -> [CategoryEntity] { - if let savedCategories = UserDefaults.standard.data(forKey: "SavedCategories"), - let decoded = try? JSONDecoder().decode([CategoryEntity].self, from: savedCategories) { - return decoded - } - - return [ - CategoryEntity(id: 1, title: "상의", itemCount: 2), - CategoryEntity(id: 2, title: "바지", itemCount: 1), - CategoryEntity(id: 3, title: "스커트", itemCount: 0), - CategoryEntity(id: 4, title: "아우터", itemCount: 0), - CategoryEntity(id: 5, title: "신발", itemCount: 1), - CategoryEntity(id: 6, title: "가방", itemCount: 0), - CategoryEntity(id: 7, title: "패션 소품", itemCount: 0) - ] - } - - func saveCategories(_ categories: [CategoryEntity]) { - if let encoded = try? JSONEncoder().encode(categories) { - UserDefaults.standard.set(encoded, forKey: "SavedCategories") - } - print("저장 완료:") - categories.forEach { print("\($0.id): \($0.title): \($0.itemCount)") } - } + // MARK: - 코디가 없는 경우의 Home 관련 - // MARK: - Cloth Items (API Mock) + /// 홈화면 - 카테고리 별 옷 더미 list func fetchClothItems(request: ClothListRequestDTO) async throws -> [ClothListResponseDTO] { - print("===== 🔵 Cloth List Request Mock =====") - print("Request Parameters:", request.toQueryParameters()) - - // 카테고리 ID에 따라 다른 데이터를 반환 + let categoryId = request.categoryId ?? 1 let mockResponse: [ClothListResponseDTO] switch categoryId { - case 1: // 상의 + case 1: mockResponse = [ - ClothListResponseDTO(clothId: 101, clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800"), // 흰 티셔츠 - ClothListResponseDTO(clothId: 102, clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800") // 셔츠 + ClothListResponseDTO(clothId: 101, clothImageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800"), + ClothListResponseDTO(clothId: 102, clothImageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800") ] - case 2: // 바지 + case 2: mockResponse = [ - ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 - ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") ] - case 3: // 바지 + case 3: mockResponse = [ - ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 - ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") ] - case 4: // 바지 + case 4: mockResponse = [ - ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 - ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") ] - case 5: // 신발 + case 5: mockResponse = [ - ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), // 빨간 운동화 - ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") // 갈색 구두 + ClothListResponseDTO(clothId: 501, clothImageUrl: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800"), + ClothListResponseDTO(clothId: 502, clothImageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?w=800") ] - case 6: // 바지 + case 6: mockResponse = [ - ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 - ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") ] - case 7: // 바지 + case 7: mockResponse = [ - ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), // 청바지 - ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") // 슬랙스 + ClothListResponseDTO(clothId: 201, clothImageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800"), + ClothListResponseDTO(clothId: 202, clothImageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?w=800") ] default: - // 나머지 카테고리는 빈 배열 혹은 기본 이미지 반환 - mockResponse = [ -// ClothListResponseDTO(clothId: Int64(categoryId * 100), clothImageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800") - ] + mockResponse = [] } - print("Response Count for Category \(categoryId):", mockResponse.count) - print("===== ✅ Mock response complete =====") - return mockResponse } - func loadClothItems() -> [HomeClothEntity] { - // 기존 메서드는 유지 (하위 호환성) - return [ - HomeClothEntity( - id: 1, - categoryId: 1, - imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=800" - ), - HomeClothEntity( - id: 2, - categoryId: 1, - imageUrl: "https://images.unsplash.com/photo-1541099649105-f69ad21f3246?w=800" - ), - HomeClothEntity( - id: 3, - categoryId: 2, - imageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800" - ), - HomeClothEntity( - id: 4, - categoryId: 5, - imageUrl: "https://images.unsplash.com/photo-1584735175315-9d5df23860b1?w=800" - ) - ] + // 오늘의 코디 추가하기 + 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 + ) + } + ) + + try await saveCodiCoordinate(requestDTO) } - // MARK: - Codi Items - func loadInitialImages() -> [DraggableImageEntity] { + // MARK: - Categorory 수정 뷰 관련 + /// 카테고리 별 개수 + func loadCategories() -> [CategoryEntity] { + if let savedCategories = UserDefaults.standard.data(forKey: "SavedCategories"), + let decoded = try? JSONDecoder().decode([CategoryEntity].self, from: savedCategories) { + return decoded + } + 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) + CategoryEntity(id: 1, title: "상의", itemCount: 2), + CategoryEntity(id: 2, title: "바지", itemCount: 1), + CategoryEntity(id: 3, title: "스커트", itemCount: 0), + CategoryEntity(id: 4, title: "아우터", itemCount: 0), + CategoryEntity(id: 5, title: "신발", itemCount: 1), + CategoryEntity(id: 6, title: "가방", itemCount: 0), + CategoryEntity(id: 7, title: "패션 소품", itemCount: 0) ] } + + /// 카테고리 적용하기 + func saveCategories(_ categories: [CategoryEntity]) { + if let encoded = try? JSONEncoder().encode(categories) { + UserDefaults.standard.set(encoded, forKey: "SavedCategories") + } + print("저장 완료:") + categories.forEach { print("\($0.id): \($0.title): \($0.itemCount)") } + } - // MARK: - Codi Items (API Mock) + // MARK: - 코디보드 + // 코디 추가하기 func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { - - print("===== 📦 Codi Coordinate Request Mock (Server API Call) =====") - print("Snapshot Image URL: \(request.coordinateImageUrl)") - print("Total Items: \(request.Payload.count)") - - // 네트워크 지연 시뮬레이션 try await Task.sleep(nanoseconds: 500_000_000) for (index, item) in request.Payload.enumerated() { @@ -243,75 +217,61 @@ final class HomeDatasource { - order: \(item.order) """) } - - print("===== ✅ Mock Server Response: Success =====") } + // 코디보드 옷 불러오기 + 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, // ← 캔버스 가로의 약 25% - y: 100, // ← 세로의 약 25% + x: 300, + y: 100, width: 70, height: 70 ), - // 상의 (왼쪽 위 근처) CodiItemEntity( id: 2, imageName: "image4", clothName: "체크 셔츠", brandName: "Polo", description: "사계절 착용 가능한 셔츠", - x: 100, // ← 캔버스 가로의 약 25% - y: 100, // ← 세로의 약 25% + x: 100, + y: 100, width: 70, height: 70 ), - // 바지 (오른쪽 중간쯤) CodiItemEntity( id: 3, imageName: "image3", clothName: "와이드 치노 팬츠", brandName: "Basic Concept", description: "사계절 착용 가능한 면 바지", - x: 300, // ← 가로 75% - y: 200, // ← 세로 35% 근처 - width: 100, - height: 100 - ), - // 신발 (왼쪽 아래 근처) - CodiItemEntity( - id: 4, - imageName: "image2", - clothName: "Dr.Martens", - brandName: "Codive Studio", - description: "구두", - x: 150, // ← 가로 35% 근처 - y: 300, // ← 세로 75% - width: 90, - height: 90 - ), - CodiItemEntity( - id: 5, - imageName: "image5", - clothName: "??", - brandName: "??", - description: "사계절 착용 가능한 ??", - x: 200, // ← 캔버스 가로의 약 25% - y: 200, // ← 세로의 약 25% + x: 300, + y: 200, width: 100, height: 100 ) ] } - - // MARK: - Date Handling + + // 오늘의 날짜 func fetchToday() -> DateEntity { let formatter = DateFormatter() formatter.dateFormat = "MM.dd" @@ -320,9 +280,8 @@ final class HomeDatasource { return DateEntity(formattedDate: todayString) } - // MARK: - LookBook (API Mock) + // 룩북에 추가 바텀시트 더미데이터 func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] { - // 네트워크 지연 시뮬레이션 try await Task.sleep(nanoseconds: 300_000_000) return [ @@ -331,23 +290,4 @@ final class HomeDatasource { LookBookBottomSheetEntity(lookbookId: 3, codiId: 103, imageUrl: "https://images.unsplash.com/photo-1596755389378-c31d21fd1273?w=800", title: "데이트룩", count: 16) ] } - - 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 - ) - } - ) - - try await saveCodiCoordinate(requestDTO) - } } diff --git a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift index 16a279b1..9d31dcd0 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -16,48 +16,41 @@ final class HomeRepositoryImpl: HomeRepository { self.dataSource = dataSource } - // MARK: - Weather + // MARK: - 날씨 + func fetchWeatherData(for location: CLLocation?) async throws -> WeatherData { return try await dataSource.fetchWeatherData(for: location) } - // MARK: - Categories - func fetchCategories() -> [CategoryEntity] { - return dataSource.loadCategories() - } + // MARK: - 코디가 없는 경우의 Home 관련 - func saveCategories(_ categories: [CategoryEntity]) { - dataSource.saveCategories(categories) - } - - // MARK: - Cloth Items - func fetchClothItems() -> [HomeClothEntity] { - dataSource.loadClothItems() - } - - // 새로운 API 기반 메서드 func fetchClothItems(request: ClothListRequestDTO) async throws -> [HomeClothEntity] { let dtoList = try await dataSource.fetchClothItems(request: request) - - // categoryId를 request에서 가져오거나 기본값 사용 + let categoryId = Int(request.categoryId ?? 1) return dtoList.toEntities(categoryId: categoryId) } - // MARK: - Codi Items + func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws { + try await dataSource.createTodayDailyCodi(codi) + } + + // MARK: - 코디보드 + func fetchInitialImages() -> [DraggableImageEntity] { dataSource.loadInitialImages() } func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { - try await dataSource.saveCodiCoordinate(request) - } + try await dataSource.saveCodiCoordinate(request) + } + + // MARK: - 코디가 있는 경우의 Home 관련 func fetchCodiItems() -> [CodiItemEntity] { dataSource.loadDummyCodiItems() } - // MARK: - Date func getToday() -> DateEntity { dataSource.fetchToday() } @@ -66,7 +59,12 @@ final class HomeRepositoryImpl: HomeRepository { return try await dataSource.fetchLookBookList() } - func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws { - try await dataSource.createTodayDailyCodi(codi) + // MARK: - 카테고리 수정 관련 + func fetchCategories() -> [CategoryEntity] { + return dataSource.loadCategories() + } + + func saveCategories(_ categories: [CategoryEntity]) { + dataSource.saveCategories(categories) } } diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index e02a91a8..80837a6c 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -53,9 +53,9 @@ struct DraggableImageEntity: Identifiable, Hashable { struct CodiItemEntity: Identifiable { let id: Int let imageName: String - let clothName: String // 추가: 옷 이름 - let brandName: String // 추가: 브랜드 이름 - let description: String // 추가: 옷 설명 + let clothName: String + let brandName: String + let description: String let x: CGFloat let y: CGFloat let width: CGFloat @@ -144,11 +144,10 @@ struct LookBookBottomSheetEntity: Identifiable, Codable { // MARK: - Cloth Tag Entity struct ClothTagEntity: Identifiable, Hashable { let id = UUID() - let title: String // 기존 brand에서 변경 (더 범용적) - let content: String // 기존 content 유지 + let title: String + let content: String var locationX: CGFloat var locationY: CGFloat - // 추가: 이미지 위치에 따른 방향 계산을 위한 프로퍼티 var isRightSide: Bool = true } diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 3777edd4..30bad1a2 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -8,28 +8,28 @@ import CoreLocation protocol HomeRepository { - // MARK: - Weather + // MARK: - 날씨 + func fetchWeatherData(for location: CLLocation?) async throws -> WeatherData - // MARK: - Categories - func fetchCategories() -> [CategoryEntity] - func saveCategories(_ categories: [CategoryEntity]) + // MARK: - 코디가 없는 경우의 Home 관련 - // MARK: - Cloth Items - func fetchClothItems() -> [HomeClothEntity] func fetchClothItems(request: ClothListRequestDTO) async throws -> [HomeClothEntity] + func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws - // MARK: - Initial Images - func fetchInitialImages() -> [DraggableImageEntity] + // MARK: - 코디보드 - // MARK: - Codi Items + func fetchInitialImages() -> [DraggableImageEntity] func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws - func fetchCodiItems() -> [CodiItemEntity] - // MARK: - Date - func getToday() -> DateEntity + // MARK: - 코디가 있는 경우의 Home 관련 + func fetchCodiItems() -> [CodiItemEntity] + func getToday() -> DateEntity func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] - func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws + // MARK: - 카테고리 수정 관련 + + func fetchCategories() -> [CategoryEntity] + func saveCategories(_ categories: [CategoryEntity]) } diff --git a/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift index 7646b2c7..2147b567 100644 --- a/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift @@ -13,6 +13,7 @@ final class AddToLookBookUseCase { self.repository = repository } + // 룩북 추가하기 바텀시트 func execute() async throws -> [LookBookBottomSheetEntity] { return try await repository.fetchLookBookList() } diff --git a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift index 4e632cf8..5d83092a 100644 --- a/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift @@ -16,12 +16,7 @@ final class CategoryUseCase { func loadCategories() -> [CategoryEntity] { return repository.fetchCategories() } - - func loadClothItems() -> [HomeClothEntity] { - return repository.fetchClothItems() - } - // 새로운 API 기반 메서드 func loadClothItems( lastClothId: Int64? = nil, size: Int = 20, diff --git a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift index 56991be3..7b32922c 100644 --- a/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift @@ -20,18 +20,16 @@ final class CodiBoardUseCase { } func saveCodiItems(_ images: [DraggableImageEntity]) async throws { - // TODO: 실제 캔버스를 캡처한 이미지의 S3 업로드 URL이 이곳에 들어가야 함 let mockSnapshotUrl = "https://codive-storage.com/previews/\(UUID().uuidString).jpg" - // Entity를 DTO로 변환 (서버 스펙에 맞춤) let payloads: [CodiCoordinatePayloadDTO] = images.enumerated().map { index, image in return CodiCoordinatePayloadDTO( - clothId: Int64(image.id), // 고유 의류 ID + clothId: Int64(image.id), locationX: Double(image.position.x), locationY: Double(image.position.y), ratio: Double(image.scale), degree: Double(image.rotationAngle), - order: index // 레이어 순서 (Z-Index가 높을수록 뒤에 위치함) + order: index ) } @@ -39,8 +37,7 @@ final class CodiBoardUseCase { coordinateImageUrl: mockSnapshotUrl, Payload: payloads ) - - // Repository를 통해 서버(또는 Mock)에 저장 + try await repository.saveCodiCoordinate(request) } } diff --git a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift index cea14a70..c50e9fb9 100644 --- a/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift +++ b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift @@ -18,6 +18,6 @@ final class TodayCodiUseCase { } func recordTodayCodi(_ codi: TodayDailyCodi) async throws { - try await repository.createTodayDailyCodi(codi) - } + try await repository.createTodayDailyCodi(codi) + } } diff --git a/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift index 43d0e074..e3cf2302 100644 --- a/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift +++ b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift @@ -7,8 +7,6 @@ import SwiftUI -// MARK: - Reusable BottomSheet - struct BottomSheet: View { @Binding var isPresented: Bool @@ -20,8 +18,7 @@ struct BottomSheet: View { @State private var translationY: CGFloat = 0 // MARK: - Initializer - - /// 바텀시트를 구성하는 데 필요한 설정 값들을 주입하는 초기화 함수 + init( isPresented: Binding, title: String? = nil, @@ -39,8 +36,7 @@ struct BottomSheet: View { } // MARK: - Body - - /// 딤 처리된 배경 위에 바텀시트를 하단 정렬로 배치하는 레이아웃 + var body: some View { ZStack(alignment: .bottom) { if isPresented { @@ -61,8 +57,7 @@ struct BottomSheet: View { private let content: Content // MARK: - Sheet Layout - - /// 상단 그랩바, 타이틀, 컨텐츠를 포함한 바텀시트 내부 레이아웃 + private var sheetBody: some View { VStack(spacing: 0) { if showsGrabber { @@ -99,8 +94,7 @@ struct BottomSheet: View { } // MARK: - Gesture - - /// 바텀시트를 아래로 드래그하여 닫을 수 있도록 처리하는 드래그 제스처 + private var dragGesture: some Gesture { DragGesture() .onChanged { value in @@ -120,8 +114,7 @@ struct BottomSheet: View { } // MARK: - Private Helpers - - /// 바텀시트를 닫고, onDismiss 콜백을 실행하는 헬퍼 함수 + private func dismiss() { withAnimation { isPresented = false } onDismiss?() @@ -131,17 +124,13 @@ struct BottomSheet: View { // MARK: - LookBook Card View -/// 썸네일, 제목, 코디 개수를 보여주는 룩북 카드 컴포넌트 struct LookBookSheetCardView: View { let entity: LookBookBottomSheetEntity - /// 외부에서 주입받는 썸네일 뷰 (예: AsyncImage, Kingfisher, 로컬 Image 등) let thumbnail: Thumbnail? - /// 카드 전체를 탭했을 때 실행할 액션 let onTap: (() -> Void)? // MARK: - Body - - /// 카드 전체를 버튼으로 감싸 탭 시 onTap 클로저를 호출하는 레이아웃 + var body: some View { Button { onTap?() @@ -186,10 +175,7 @@ struct LookBookSheetCardView: View { } .buttonStyle(.plain) } - - // MARK: - Placeholder - /// 썸네일이 없거나 로딩 실패 시 표시할 기본 이미지 private var defaultImage: some View { Image(systemName: "photo") .resizable() @@ -201,14 +187,10 @@ struct LookBookSheetCardView: View { // MARK: - AddBottomSheet (룩북 선택 바텀시트) -/// 룩북 리스트를 그리드 형태로 보여주고, 선택 시 콜백을 전달하는 바텀시트 struct AddBottomSheet: View { @Binding var isPresented: Bool - /// 룩북 바텀시트에 표시할 엔티티 리스트 let entities: [LookBookBottomSheetEntity] - /// 각 엔티티에 대한 썸네일 뷰를 외부에서 주입 (예: AsyncImage, KFImage 등) let thumbnailProvider: (LookBookBottomSheetEntity) -> Thumbnail? - /// 엔티티 선택 시 콜백 (lookbookId, codiId 등을 상위에서 활용) let onTapEntity: (LookBookBottomSheetEntity) -> Void private let columns: [GridItem] = [ @@ -217,8 +199,7 @@ struct AddBottomSheet: View { ] // MARK: - Body - - /// 공통 BottomSheet 위에 룩북 카드들을 2열 그리드로 배치하는 레이아웃 + var body: some View { BottomSheet( isPresented: $isPresented, @@ -230,9 +211,10 @@ struct AddBottomSheet: View { ForEach(entities) { entity in LookBookSheetCardView( entity: entity, - thumbnail: thumbnailProvider(entity), - onTap: { onTapEntity(entity) } - ) + thumbnail: thumbnailProvider(entity) + ) { + onTapEntity(entity) + } } } .padding(.vertical, 8) @@ -240,34 +222,3 @@ struct AddBottomSheet: View { } } } - -// MARK: - Preview - -/// 룩북 선택 바텀시트의 레이아웃과 스타일을 확인하기 위한 프리뷰 -struct AddBottomSheet_Previews: PreviewProvider { - static var previews: some View { - let sampleEntities: [LookBookBottomSheetEntity] = [ - .init(lookbookId: 1, codiId: 101, imageUrl: "https://example.com/1.png", title: "운동룩", count: 6), - .init(lookbookId: 2, codiId: 102, imageUrl: "https://example.com/2.png", title: "출근룩", count: 12), - .init(lookbookId: 3, codiId: 103, imageUrl: "https://example.com/3.png", title: "데이트룩", count: 16), - .init(lookbookId: 4, codiId: 104, imageUrl: "https://example.com/4.png", title: "독서실룩", count: 8), - .init(lookbookId: 5, codiId: 105, imageUrl: "https://example.com/5.png", title: "스페인여행", count: 20) - ] - - ZStack { - Color(.systemGray5) - .ignoresSafeArea() - - AddBottomSheet( - isPresented: .constant(true), - entities: sampleEntities, - thumbnailProvider: { _ in - Image(systemName: "photo") - .resizable() - .scaledToFill() - }, - onTapEntity: { _ in } - ) - } - } -} diff --git a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift index cc842074..3aca0e40 100644 --- a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift +++ b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift @@ -77,21 +77,3 @@ struct CategoryCounterView: View { } } } - -#Preview { - PreviewWrapper() -} - -private struct PreviewWrapper: View { - @State var topCount = 1 - - var body: some View { - CategoryCounterView( - title: "상의", - count: $topCount, - totalCount: 1, - isFixed: true - ) - .padding() - } -} diff --git a/Codive/Features/Home/Presentation/Component/CodiButton.swift b/Codive/Features/Home/Presentation/Component/CodiButton.swift index a6050f31..5eb00fa7 100644 --- a/Codive/Features/Home/Presentation/Component/CodiButton.swift +++ b/Codive/Features/Home/Presentation/Component/CodiButton.swift @@ -33,11 +33,3 @@ struct CodiButton: View { } } } - -#Preview { - HStack(spacing: 16) { - CodiButton(iconName: "plus", title: TextLiteral.Home.edit) { } - CodiButton(iconName: "shuffle", title: TextLiteral.Home.random) { } - } - .padding() -} diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index 450bc5c5..bf7e20eb 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -262,15 +262,3 @@ struct CodiClothView: View { } } } - -// MARK: - Preview - -#Preview { - // 빈 상태 미리보기 (3칸 + 안내 카드) - CodiClothView(title: "바지", items: [], isEmptyState: true) - .padding(.horizontal, 20) - CodiClothView(title: "바지", items: [], isEmptyState: true) - .padding(.horizontal, 20) - CodiClothView(title: "바지", items: [], isEmptyState: true) - .padding(.horizontal, 20) -} diff --git a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift index d7f15dce..943e0c90 100644 --- a/Codive/Features/Home/Presentation/Component/CompletePopUp.swift +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -93,58 +93,4 @@ struct CodiCompositeView: View { .frame(width: containerSize, height: containerSize) .clipped() } - -// private func calculatePosition(for index: Int, totalCount: Int) -> CGPoint { -// let center = containerSize / 2 -// -// let stepFor3 = itemSize - 25 // 세로 25 겹침 (간격 75) -// let stepFor4 = itemSize - 36 // 세로 36 겹침 (간격 64) -// let horizontalOverlap: CGFloat = 12 -// let horizontalStep = itemSize - horizontalOverlap // 수평 12 겹침 (간격 88) -// -// switch totalCount { -// case 1: -// return CGPoint(x: center, y: center) -// -// case 2: -// let totalW = itemSize + stepFor3 -// let startX = (containerSize - totalW) / 2 + (itemSize / 2) -// return CGPoint(x: startX + (CGFloat(index) * stepFor3), y: center) -// -// case 3: -// let totalH = itemSize + (stepFor3 * 2) -// let startY = (containerSize - totalH) / 2 + (itemSize / 2) -// return CGPoint(x: center, y: startY + (CGFloat(index) * stepFor3)) -// -// case 4...7: -// // 열 구분 (7개일 때만 왼쪽이 4개, 그 외에는 왼쪽 3개 배치) -// let leftCount = (totalCount == 7) ? 4 : 3 -// let rightCount = totalCount - leftCount -// -// // 1. 세로 시작점(startY) 계산: 왼쪽 열 기준 중앙 정렬 -// let leftStep = (leftCount == 4) ? stepFor4 : stepFor3 -// let leftTotalH = itemSize + (leftStep * CGFloat(leftCount - 1)) -// let startY = (containerSize - leftTotalH) / 2 + (itemSize / 2) -// -// // 2. 가로 위치(xPos) 계산: 두 열 사이 12pt 겹침 적용 -// // 두 열의 총 너비 = itemSize + horizontalStep (88) = 188 -// let totalW = itemSize + horizontalStep -// let startX = (containerSize - totalW) / 2 + (itemSize / 2) -// -// let isLeftColumn = index < leftCount -// let internalIndex = isLeftColumn ? index : index - leftCount -// -// // 왼쪽 열은 startX, 오른쪽 열은 startX + 88 -// let xPos = isLeftColumn ? startX : startX + horizontalStep -// -// // 3. 세로 위치(yPos) 계산 -// let currentColumnTotal = isLeftColumn ? leftCount : rightCount -// let step = (currentColumnTotal == 4) ? stepFor4 : stepFor3 -// -// return CGPoint(x: xPos, y: startY + (CGFloat(internalIndex) * step)) -// -// default: -// return CGPoint(x: center, y: center) -// } -// } } diff --git a/Codive/Features/Home/Presentation/View/CodiBoardView.swift b/Codive/Features/Home/Presentation/View/CodiBoardView.swift index aba2cc57..1e0ca1f1 100644 --- a/Codive/Features/Home/Presentation/View/CodiBoardView.swift +++ b/Codive/Features/Home/Presentation/View/CodiBoardView.swift @@ -8,84 +8,116 @@ import SwiftUI struct CodiBoardView: View { + + // MARK: - Properties @StateObject private var viewModel: CodiBoardViewModel - + + // MARK: - Initialization init(viewModel: CodiBoardViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } - + + // MARK: - Body var body: some View { VStack(spacing: 0) { - CustomNavigationBar(title: TextLiteral.Home.codiBoardTitle) { - viewModel.handleBackTap() - } - + navigationBar + GeometryReader { geometry in let boardSize = geometry.size.width - 40 let imageHalfSize: CGFloat = 40 - let minBound = imageHalfSize - let maxBound = boardSize - imageHalfSize - - ScrollView { - VStack { - Text(TextLiteral.Home.codiBoardDescription) - .font(Font.codive_title2) - .foregroundStyle(Color.Codive.grayscale1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.vertical, 24) - - ZStack { - boardBackground(size: boardSize) - - ForEach($viewModel.images) { $image in - DraggableImageView( - image: $image, - imageHalfSize: imageHalfSize, - minBound: minBound, - maxBound: maxBound, - viewModel: viewModel - ) - } - } - .frame(width: boardSize, height: boardSize) - .padding(.horizontal, 20) - .padding(.bottom, 20) - } - .frame(width: geometry.size.width) - } - .safeAreaInset(edge: .bottom) { - CustomButton( - text: TextLiteral.Home.complete, - widthType: .fixed, - action: viewModel.handleConfirmCodi - ) - .padding(.horizontal, 20) - .padding(.vertical, 16) - .background(alignment: .center) { - Color.white - } - } + + contentView(boardSize: boardSize, imageHalfSize: imageHalfSize, totalWidth: geometry.size.width) } } .navigationBarHidden(true) - .background(alignment: .center) { - Color.white - } + .background(Color.white) .onChange(of: viewModel.isConfirmed) { confirmed in - if confirmed { } + if confirmed { + // 확인 로직 처리 + } + } + } +} + +// MARK: - View Components +private extension CodiBoardView { + + /// 상단 커스텀 네비게이션 바 + var navigationBar: some View { + CustomNavigationBar(title: TextLiteral.Home.codiBoardTitle) { + viewModel.handleBackTap() + } + } + + /// 메인 컨텐츠 영역 (설명 + 보드) + func contentView(boardSize: CGFloat, imageHalfSize: CGFloat, totalWidth: CGFloat) -> some View { + ScrollView { + VStack(spacing: 0) { + descriptionText + + drawingBoard(size: boardSize, imageHalfSize: imageHalfSize) + } + .frame(width: totalWidth) + } + .safeAreaInset(edge: .bottom) { + confirmationButton } } - @ViewBuilder - private func boardBackground(size: CGFloat) -> some View { + /// 상단 설명 텍스트 + var descriptionText: some View { + Text(TextLiteral.Home.codiBoardDescription) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + + /// 이미지들을 배치하고 드래그할 수 있는 보드 영역 + func drawingBoard(size: CGFloat, imageHalfSize: CGFloat) -> some View { + let minBound = imageHalfSize + let maxBound = size - imageHalfSize + + return ZStack { + boardBackground(size: size) + + ForEach($viewModel.images) { $image in + DraggableImageView( + image: $image, + imageHalfSize: imageHalfSize, + minBound: minBound, + maxBound: maxBound, + viewModel: viewModel + ) + } + } + .frame(width: size, height: size) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + /// 보드의 배경 디자인 + func boardBackground(size: CGFloat) -> some View { RoundedRectangle(cornerRadius: 15) .fill(Color.Codive.grayscale7) .frame(width: size, height: size) - .overlay(alignment: .center) { + .overlay( RoundedRectangle(cornerRadius: 15) .stroke(Color.Codive.grayscale5, lineWidth: 1) - } + ) .shadow(color: .black.opacity(0.1), radius: 5, y: 2) } + + /// 하단 확정 버튼 + var confirmationButton: some View { + CustomButton( + text: TextLiteral.Home.complete, + widthType: .fixed, + action: viewModel.handleConfirmCodi + ) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color.white) + } } diff --git a/Codive/Features/Home/Presentation/View/EditCategoryView.swift b/Codive/Features/Home/Presentation/View/EditCategoryView.swift index 568b066d..e9b04622 100644 --- a/Codive/Features/Home/Presentation/View/EditCategoryView.swift +++ b/Codive/Features/Home/Presentation/View/EditCategoryView.swift @@ -8,73 +8,108 @@ import SwiftUI struct EditCategoryView: View { + + // MARK: - Properties @StateObject private var viewModel: EditCategoryViewModel + // MARK: - Initializer init(viewModel: EditCategoryViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } + // MARK: - Body var body: some View { VStack(spacing: 0) { - CustomNavigationBar(title: TextLiteral.Home.editCategoryTitle) { - viewModel.handleBackTap() - } + navigationBar ScrollView { - VStack { - Text("\(TextLiteral.Home.currentCategoryCount) (\(viewModel.totalCount)/7)") - .font(Font.codive_title2) - .foregroundStyle(Color.Codive.grayscale1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(EdgeInsets(top: 24, leading: 20, bottom: 24, trailing: 20)) - - VStack(spacing: 24) { - ForEach($viewModel.categories, id: \.id) { $category in - CategoryCounterView( - title: category.title, - count: $category.itemCount, - totalCount: viewModel.totalCount, - isFixed: viewModel.isFixed(category: category) // 고정 정보 전달 - ) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 20) + VStack(spacing: 0) { + categoryHeader + categoryList } } .safeAreaInset(edge: .bottom) { - HStack(spacing: 9) { - CustomButton(text: TextLiteral.Home.reset, widthType: .half, styleType: .border) { - viewModel.resetCounts() - } - CustomButton( - text: TextLiteral.Home.apply, - widthType: .half, - isEnabled: viewModel.isApplyButtonEnabled - ) { - viewModel.applyChanges() - } - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .background(alignment: .center) { - Color.white - } + bottomActionButtons } } .navigationBarHidden(true) - .background(alignment: .center) { - Color.white - } + .background(Color.white) .alert(TextLiteral.Home.changeAlertTitle, isPresented: $viewModel.showExitAlert) { - Button(TextLiteral.Common.cancel, role: .cancel) { - viewModel.cancelExit() - } - Button(TextLiteral.Home.leave, role: .destructive) { - viewModel.confirmExit() - } + alertButtons } message: { Text(TextLiteral.Home.changeAlertMessage) } } } + +// 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) + .foregroundStyle(Color.Codive.grayscale1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + + /// 카테고리 아이템 리스트 + var categoryList: some View { + VStack(spacing: 24) { + ForEach($viewModel.categories, id: \.id) { $category in + CategoryCounterView( + title: category.title, + count: $category.itemCount, + totalCount: viewModel.totalCount, + isFixed: viewModel.isFixed(category: category) + ) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + /// 하단 초기화 및 적용 버튼 + var bottomActionButtons: some View { + HStack(spacing: 9) { + CustomButton( + text: TextLiteral.Home.reset, + widthType: .half, + styleType: .border + ) { + viewModel.resetCounts() + } + + CustomButton( + text: TextLiteral.Home.apply, + widthType: .half, + isEnabled: viewModel.isApplyButtonEnabled + ) { + viewModel.applyChanges() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color.white) + } + + /// 수정 취소 시 나타나는 알럿 버튼들 + @ViewBuilder + var alertButtons: some View { + Button(TextLiteral.Common.cancel, role: .cancel) { + viewModel.cancelExit() + } + Button(TextLiteral.Home.leave, role: .destructive) { + viewModel.confirmExit() + } + } +} diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index 9d0ac93e..4779037b 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -8,39 +8,36 @@ import SwiftUI struct HomeHasCodiView: View { + + // MARK: - Properties @ObservedObject var viewModel: HomeViewModel let width: CGFloat + // MARK: - Body var body: some View { ZStack(alignment: .topTrailing) { - VStack(alignment: .leading, spacing: 16) { header + codiDisplayArea if viewModel.showClothSelector { clothSelector } - CustomBanner(text: TextLiteral.Home.bannerTitle) { - viewModel.rememberCodi() - } - .padding() + bottomBanner } - CustomOverflowMenu( - menuType: .coordination, - menuActions: [ - { viewModel.selectEditCodi() }, - { viewModel.addLookbook() }, - { viewModel.sharedCodi() } - ] - ) - .zIndex(9999) + overflowMenu } } +} + +// MARK: - View Components +private extension HomeHasCodiView { - private var header: some View { + /// 상단 헤더 (오늘의 코디 제목 및 날짜) + var header: some View { HStack { Text("\(TextLiteral.Home.todayCodiTitle)(\(viewModel.todayString))") .font(.title2) @@ -49,22 +46,16 @@ struct HomeHasCodiView: View { } } - private var codiDisplayArea: some View { - // 가장 바깥쪽 ZStack에 GeometryReader를 사용하여 전체 캔버스 크기를 잡습니다. + /// 코디 아이템 및 태그가 표시되는 메인 캔버스 영역 + var codiDisplayArea: some View { GeometryReader { canvasProxy in let canvasSize = canvasProxy.size ZStack(alignment: .bottomLeading) { - // 1. 배경 사각형 - RoundedRectangle(cornerRadius: 15) - .fill(Color.Codive.grayscale7) - .frame(width: canvasSize.width, height: canvasSize.height) - .overlay { - RoundedRectangle(cornerRadius: 15) - .stroke(Color.gray.opacity(0.4), lineWidth: 1) - } + // 배경 레이어 + boardBackground(size: canvasSize) - // 2. 코디 아이템들 (이미지 레이어) + // 이미지 아이템 레이어 ForEach(viewModel.codiItems) { item in Image(item.imageName) .resizable() @@ -73,56 +64,22 @@ struct HomeHasCodiView: View { .position(x: item.x, y: item.y) } + // 선택된 아이템의 태그 레이어 if let selectedID = viewModel.selectedItemID, let selectedItem = viewModel.codiItems.first(where: { $0.id == selectedID }) { - - // 아이템이 캔버스의 왼쪽에 더 가까운지 오른쪽에 더 가까운지 판단 - let distanceToLeft = selectedItem.x - let distanceToRight = canvasSize.width - selectedItem.x - let shouldShowOnRight = distanceToLeft < distanceToRight - - // 태그의 기본 오프셋 - let tagOffset: CGFloat = 120 - - ForEach(viewModel.selectedItemTags) { tag in - // 태그의 기본 위치 계산 - let baseX = selectedItem.x + (tag.locationX - 0.5) * selectedItem.width - let baseY = selectedItem.y + (tag.locationY - 0.5) * selectedItem.height - - // 태그를 좌우로 배치 - let tagX = baseX + (shouldShowOnRight ? tagOffset : -tagOffset) - - // 태그가 캔버스 밖으로 나가지 않도록 조정 - // 태그의 대략적인 너비를 100으로 가정 (실제 너비에 맞게 조정 필요) - let tagWidth: CGFloat = 100 - let tagHeight: CGFloat = 40 - - 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) - } + tagOverlay(for: selectedItem, in: canvasSize) } - Button(action: viewModel.toggleClothSelector) { - Image("ic_tag") - .resizable() - .frame(width: 28, height: 28) - } - .padding([.leading, .bottom], 16) + // 태그 셀렉터 토글 버튼 + tagToggleButton } } .frame(width: max(width - 40, 0), height: max(width - 40, 0)) .padding(.horizontal, 20) } - private var clothSelector: some View { + /// 하단 의류 선택 스크롤 뷰 + var clothSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(viewModel.codiItems) { item in @@ -130,12 +87,8 @@ struct HomeHasCodiView: View { entity: item, isSelected: Binding( get: { viewModel.selectedItemID == item.id }, - set: { newValue in - if newValue { - viewModel.selectItem(item.id) - } else if viewModel.selectedItemID == item.id { - viewModel.selectItem(nil) - } + set: { isSelected in + viewModel.selectItem(isSelected ? item.id : nil) } ) ) @@ -144,4 +97,75 @@ struct HomeHasCodiView: View { .padding(.horizontal, 20) } } + + /// 하단 배너 + var bottomBanner: some View { + CustomBanner(text: TextLiteral.Home.bannerTitle) { + viewModel.rememberCodi() + } + .padding() + } + + /// 우측 상단 오버플로우 메뉴 + var overflowMenu: some View { + CustomOverflowMenu( + menuType: .coordination, + menuActions: [ + { viewModel.selectEditCodi() }, + { viewModel.addLookbook() }, + { viewModel.sharedCodi() } + ] + ) + .zIndex(9999) + } +} + +// 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) + .overlay { + RoundedRectangle(cornerRadius: 15) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + } + } + + /// 태그 표시 로직 분리 + @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) + } + } + + /// 태그 표시 토글 버튼 + var tagToggleButton: some View { + Button(action: viewModel.toggleClothSelector) { + Image("ic_tag") + .resizable() + .frame(width: 28, height: 28) + } + .padding([.leading, .bottom], 16) + } } diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index 0e1e2dfe..eb7bbf29 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -8,14 +8,21 @@ import SwiftUI struct HomeNoCodiView: View { + + // MARK: - Properties @ObservedObject var viewModel: HomeViewModel + // MARK: - Body var body: some View { - VStack { + VStack(spacing: 0) { header + categoryButtons + codiClothList + Spacer() + if !viewModel.isAllCategoriesEmpty { bottomButtons } @@ -24,28 +31,39 @@ struct HomeNoCodiView: View { viewModel.onAppear() } } +} - private var header: some View { +// MARK: - View Components +private extension HomeNoCodiView { + + /// 상단 헤더 타이틀 + var header: some View { Text(TextLiteral.Home.noCodiTitle) - .font(Font.codive_title1) + .font(.codive_title1) .foregroundStyle(Color.Codive.grayscale1) .frame(maxWidth: .infinity, alignment: .leading) - .padding(EdgeInsets(top: 24, leading: 20, bottom: 16, trailing: 20)) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 16) } - private var categoryButtons: some View { + /// 카테고리 편집 및 랜덤 설정 버튼 영역 + var categoryButtons: some View { HStack(spacing: 8) { CodiButton(iconName: "plus", title: TextLiteral.Home.edit) { viewModel.handleEditCategory() } - CodiButton(iconName: "shuffle", title: TextLiteral.Home.random) {} + CodiButton(iconName: "shuffle", title: TextLiteral.Home.random) { + // TODO: 랜덤 코디 로직 연결 필요 + } } .padding(.horizontal, 20) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 16) } - private var codiClothList: some View { + /// 카테고리별 의류 리스트 (가로 스크롤 영역들) + var codiClothList: some View { VStack(spacing: 16) { ForEach(viewModel.activeCategories) { category in let clothItems = viewModel.clothItemsByCategory[category.id] ?? [] @@ -55,51 +73,61 @@ struct HomeNoCodiView: View { items: clothItems, isEmptyState: clothItems.isEmpty, onIndexChanged: { newIndex in - // 핵심: 사용자가 스크롤 할 때마다 ViewModel의 딕셔너리 업데이트 + // 사용자가 스크롤 할 때마다 ViewModel의 선택 인덱스 업데이트 viewModel.updateSelectedIndex(for: category.id, index: newIndex) } ) + // 데이터 변경 시 뷰 갱신을 위한 식별자 지정 .id("\(category.id)-\(clothItems.count)") } } .padding(.horizontal, 20) } - // TODO: CustomButton에 비율 지정 기능 추가 후 리팩토링 필요 - private var bottomButtons: some View { + /// 하단 액션 버튼 (코디판 이동 및 코디 확정) + var bottomButtons: some View { GeometryReader { geometry in let totalWidth = geometry.size.width - 40 let availableWidth = totalWidth - 16 - let button1Width = availableWidth / 3 - let button2Width = availableWidth * 2 / 3 - + HStack(spacing: 16) { - Button(action: viewModel.handleCodiBoardTap) { - Text(TextLiteral.Home.codiBoardTitle) - .font(Font.codive_title2) - .foregroundStyle(Color.Codive.main0) - .frame(width: button1Width, height: 48) - } + // 코디판 버튼 (1/3 비율) + codiBoardButton(width: availableWidth / 3) + + // 결정하기 버튼 (2/3 비율) + confirmButton(width: availableWidth * 2 / 3) + } + .padding(.horizontal, 20) + } + .frame(height: 48) + .padding(.top, 24) + .padding(.bottom, 48) + } + + /// 코디판으로 이동하는 버튼 + func codiBoardButton(width: CGFloat) -> some View { + Button(action: viewModel.handleCodiBoardTap) { + Text(TextLiteral.Home.codiBoardTitle) + .font(.codive_title2) + .foregroundStyle(Color.Codive.main0) + .frame(width: width, height: 48) .background(Color.white) .overlay { RoundedRectangle(cornerRadius: 10) .stroke(Color.Codive.main0, lineWidth: 1) } .clipShape(RoundedRectangle(cornerRadius: 10)) - - Button(action: viewModel.handleConfirmCodiTap) { - Text(TextLiteral.Home.decesion) - .font(Font.codive_title2) - .foregroundStyle(.white) - .frame(width: button2Width, height: 48) - } + } + } + + /// 현재 조합으로 코디를 확정하는 버튼 + func confirmButton(width: CGFloat) -> some View { + Button(action: viewModel.handleConfirmCodiTap) { + Text(TextLiteral.Home.decesion) + .font(.codive_title2) + .foregroundStyle(.white) + .frame(width: width, height: 48) .background(Color.Codive.main0) .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .padding(.horizontal, 20) } - .frame(height: 48) - .padding(.top, 24) - .padding(.bottom, 48) - } } diff --git a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift index 8c32eb20..4f560424 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -11,6 +11,7 @@ import SwiftUI final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtocol { // MARK: - Properties + @Published var isConfirmed: Bool = false @Published var images: [DraggableImageEntity] = [] @Published var currentlyDraggedID: Int? @@ -20,6 +21,7 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco private weak var homeViewModel: HomeViewModel? // MARK: - Initializer + init( navigationRouter: NavigationRouter, codiBoardUseCase: CodiBoardUseCase, @@ -28,63 +30,79 @@ final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtoco self.navigationRouter = navigationRouter self.codiBoardUseCase = codiBoardUseCase self.homeViewModel = homeViewModel + loadInitialData() } - // MARK: - Data Loading + // MARK: - Private Methods + + /// 초기 코디판 이미지 데이터를 로드 private func loadInitialData() { - images = codiBoardUseCase.loadCodiBoardImages() + self.images = codiBoardUseCase.loadCodiBoardImages() } // MARK: - Navigation + + /// 이전 화면으로 이동 func handleBackTap() { navigationRouter.navigateBack() } // MARK: - Actions + + /// 구성된 코디를 서버에 저장하고 홈 화면의 완료 팝업을 띄움 func handleConfirmCodi() { Task { do { - // 서버에 데이터 전송 + // 1. 서버에 데이터 전송 try await codiBoardUseCase.saveCodiItems(images) - // 전송 완료 후 UI 로직 실행 - await MainActor.run { - 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 + // 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 { - print("코디 저장 실패: \(error.localizedDescription)") - // 필요 시 사용자에게 알림(에러 팝업 등) + handleError(error) } } } - // MARK: - Image Manipulation (DraggableImageViewModelProtocol) + /// 에러 발생 시 처리 로직 + private func handleError(_ error: Error) { + print("코디 저장 실패: \(error.localizedDescription)") + // TODO: 필요한 경우 사용자에게 보여줄 에러 알럿 로직 추가 + } + + // MARK: - DraggableImageViewModelProtocol Implementation + + /// 특정 이미지를 레이어의 최상단으로 가져옴 func bringImageToFront(id: Int) { - if let index = images.firstIndex(where: { $0.id == id }) { - let tapped = images.remove(at: index) - images.append(tapped) - } + guard let index = images.firstIndex(where: { $0.id == id }) else { return } + let tappedImage = images.remove(at: index) + images.append(tappedImage) } + /// 이미지의 위치(Position)를 업데이트 func updateImagePosition(id: Int, newPosition: CGPoint) { if let index = images.firstIndex(where: { $0.id == id }) { images[index].position = newPosition } } + /// 이미지의 크기(Scale)를 업데이트 func updateImageScale(id: Int, newScale: CGFloat) { if let index = images.firstIndex(where: { $0.id == id }) { images[index].scale = newScale } } + /// 이미지의 회전 각도(Rotation)를 업데이트 func updateImageRotation(id: Int, newRotation: Double) { if let index = images.firstIndex(where: { $0.id == id }) { images[index].rotationAngle = newRotation diff --git a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift index c08f0508..620784bc 100644 --- a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift @@ -10,30 +10,27 @@ import SwiftUI @MainActor final class EditCategoryViewModel: ObservableObject { - // MARK: - Properties - private let navigationRouter: NavigationRouter - - // 전체 최대 개수를 7로 설정 - private let maxTotalCount = 7 - // 고정 카테고리 ID (상의: 1, 바지: 2, 신발: 5) - private let fixedCategoryIds: Set = [] - - var totalCount: Int { categories.reduce(0) { $0 + $1.itemCount } } - - var hasChanges: Bool { - return categories.map { $0.itemCount } != initialCategories.map { $0.itemCount } - } - - var isApplyButtonEnabled: Bool { - return hasChanges && totalCount > 0 - } + // MARK: - Properties (State & Storage) - @AppStorage("SavedCategories") private var savedCategoriesData: Data? @Published var categories: [CategoryEntity] = [] @Published var showExitAlert: Bool = false - + + @AppStorage("SavedCategories") private var savedCategoriesData: Data? + + /// 변경 사항 확인을 위한 초기 상태 백업 private var initialCategories: [CategoryEntity] = [] + // MARK: - Properties (Constants & Dependencies) + + private let navigationRouter: NavigationRouter + + /// 전체 카테고리 아이템의 최대 합계 + private let maxTotalCount = 7 + + /// 고정 카테고리 ID (현재 비어 있음 - 필요 시 상의: 1, 바지: 2, 신발: 5 등을 추가) + private let fixedCategoryIds: Set = [] + + /// 기본 카테고리 구성 정보 private static var allCategories: [CategoryEntity] = [ CategoryEntity(id: 1, title: "상의", itemCount: 1), CategoryEntity(id: 2, title: "바지", itemCount: 1), @@ -44,72 +41,101 @@ final class EditCategoryViewModel: ObservableObject { CategoryEntity(id: 7, title: "패션 소품", itemCount: 0) ] + // MARK: - Computed Properties + + /// 현재 선택된 모든 카테고리 아이템의 총합 + var totalCount: Int { + categories.reduce(0) { $0 + $1.itemCount } + } + + /// 처음 진입 시와 비교하여 변경 사항이 있는지 여부 + var hasChanges: Bool { + categories.map { $0.itemCount } != initialCategories.map { $0.itemCount } + } + + /// 적용 버튼 활성화 상태 (변경 사항이 있고, 총합이 0보다 클 때) + var isApplyButtonEnabled: Bool { + hasChanges && totalCount > 0 + } + // MARK: - Initializer + init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter loadInitialData() } - // MARK: - Data Loading + // MARK: - Data Methods (Private) + + /// 저장된 데이터를 불러오거나 초기 데이터를 설정 private func loadInitialData() { if let data = savedCategoriesData, - let decodedCategories = try? JSONDecoder().decode([CategoryEntity].self, from: data) { - // 저장된 데이터가 있더라도 고정 항목은 항상 1로 강제 유지 - self.categories = decodedCategories.map { category in + let decoded = try? JSONDecoder().decode([CategoryEntity].self, from: data) { + + // 데이터 로드 시 고정 항목 규칙 적용 + self.categories = decoded.map { category in var updated = category - if fixedCategoryIds.contains(category.id) { + if isFixed(category: category) { updated.itemCount = 1 } return updated } } else { + // 저장된 데이터가 없는 경우 기본값 사용 self.categories = Self.allCategories - if let encoded = try? JSONEncoder().encode(Self.allCategories) { - savedCategoriesData = encoded - } + saveToStorage(categories: Self.allCategories) } + + // 초기 비교를 위한 상태 백업 self.initialCategories = self.categories } - // 고정 여부 확인 함수 + /// AppStorage에 현재 카테고리 상태를 저장 + private func saveToStorage(categories: [CategoryEntity]) { + if let encoded = try? JSONEncoder().encode(categories) { + savedCategoriesData = encoded + } + } + + // MARK: - Category Logic + + /// 해당 카테고리가 변경 불가능한 고정 항목인지 확인 func isFixed(category: CategoryEntity) -> Bool { return fixedCategoryIds.contains(category.id) } - // MARK: - Category Count Handling + /// 카테고리 아이템 개수 증가 (최대 1개, 전체 7개 제한) func incrementCount(for category: CategoryEntity) { - // 고정 제약이 없으므로 더 자유롭게 증가 가능 - guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - - // 개별 카테고리 최대 1개 & 전체 합 7개 미만일 때만 증가 - if categories[index].itemCount < 1 && totalCount < maxTotalCount { - categories[index].itemCount += 1 - } + guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } + + if categories[index].itemCount < 1 && totalCount < maxTotalCount { + categories[index].itemCount += 1 } + } + + /// 카테고리 아이템 개수 감소 + func decrementCount(for category: CategoryEntity) { + guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - func decrementCount(for category: CategoryEntity) { - guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - - // 0보다 클 때만 감소 가능 - if categories[index].itemCount > 0 { - categories[index].itemCount -= 1 - } + if categories[index].itemCount > 0 { + categories[index].itemCount -= 1 } + } - // MARK: - Reset + /// 현재 상태를 초기 상태로 되돌림 func resetCounts() { self.categories = initialCategories } - // MARK: - Apply Changes + /// 변경 사항을 저장하고 이전 화면으로 이동 func applyChanges() { - if let encoded = try? JSONEncoder().encode(categories) { - savedCategoriesData = encoded - } + saveToStorage(categories: categories) navigationRouter.navigateBack() } - // MARK: - Navigation + // MARK: - Navigation & Alert Handling + + /// 뒤로가기 버튼 탭 처리 (변경 사항 발생 시 알럿 표시) func handleBackTap() { if hasChanges { showExitAlert = true @@ -118,11 +144,13 @@ final class EditCategoryViewModel: ObservableObject { } } + /// 알럿에서 나가기 확인 시 func confirmExit() { showExitAlert = false navigationRouter.navigateBack() } + /// 알럿에서 취소 시 func cancelExit() { showExitAlert = false } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 7cfb2b32..cd46858e 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -6,55 +6,58 @@ // import SwiftUI -import UIKit import Combine import CoreLocation @MainActor final class HomeViewModel: ObservableObject { - // MARK: - Properties + // MARK: - Properties (UI State) + @Published var hasCodi: Bool = false - @Published var selectedIndex: Int? = 0 @Published var showClothSelector: Bool = false + @Published var selectedItemID: Int? + @Published var selectedIndex: Int? = 0 @Published var titleFrame: CGRect = .zero + @Published var showCompletePopUp: Bool = false + @Published var showLookBookSheet: Bool = false + @Published var completedCodiImageURL: String? + + // MARK: - Properties (Data) + @Published var weatherData: WeatherData? @Published var weatherErrorMessage: String? @Published var todayString: String = "" - @Published var selectedItemID: Int? + @Published var codiItems: [CodiItemEntity] = [] @Published var selectedItemTags: [ClothTagEntity] = [] @Published var activeCategories: [CategoryEntity] = [] + @Published var lookBookList: [LookBookBottomSheetEntity] = [] @Published var clothItemsByCategory: [Int: [HomeClothEntity]] = [:] - @Published var selectedCodiClothes: [HomeClothEntity] = [] @Published var selectedIndicesByCategory: [Int: Int] = [:] + @Published var selectedCodiClothes: [HomeClothEntity] = [] - // 팝업 관련 프로퍼티 추가 - @Published var showCompletePopUp: Bool = false - @Published var completedCodiImageURL: String? + // MARK: - Dependencies - // 바텀시트 관련 프로퍼티 추가 - @Published var showLookBookSheet: Bool = false - @Published var lookBookList: [LookBookBottomSheetEntity] = [] + let navigationRouter: NavigationRouter + private let fetchWeatherUseCase: FetchWeatherUseCase + private let todayCodiUseCase: TodayCodiUseCase + private let dateUseCase: DateUseCase + private let categoryUseCase: CategoryUseCase + private let addToLookBookUseCase: AddToLookBookUseCase + // MARK: - Computed Properties + + /// 현재 활성화된 모든 카테고리에 아이템이 하나도 없는지 확인 var isAllCategoriesEmpty: Bool { - // activeCategories에 있는 각 카테고리의 아이템 개수를 모두 더함 let totalItemCount = activeCategories.reduce(0) { sum, category in sum + (clothItemsByCategory[category.id]?.count ?? 0) } return totalItemCount == 0 } - let navigationRouter: NavigationRouter - - // MARK: - UseCases - private let fetchWeatherUseCase: FetchWeatherUseCase - private let todayCodiUseCase: TodayCodiUseCase - private let dateUseCase: DateUseCase - private let categoryUseCase: CategoryUseCase - private let addToLookBookUseCase: AddToLookBookUseCase - // MARK: - Initializer + init( navigationRouter: NavigationRouter, fetchWeatherUseCase: FetchWeatherUseCase, @@ -70,45 +73,64 @@ final class HomeViewModel: ObservableObject { self.categoryUseCase = categoryUseCase self.addToLookBookUseCase = addToLookBookUseCase + loadInitialData() + } + + // MARK: - Life Cycle + + func onAppear() { + loadActiveCategories() + } +} + +// MARK: - Data Loading Methods +private extension HomeViewModel { + + /// 앱 실행 시 필요한 초기 데이터를 로드 + func loadInitialData() { loadDummyCodi() loadToday() loadActiveCategories() } - // MARK: - Data Loading + /// 현재 날짜 정보를 가져옴 + 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 data = try await fetchWeatherUseCase.execute(for: location) - weatherData = data + weatherData = try await fetchWeatherUseCase.execute(for: location) } catch { - print("Failed to fetch weather:", error) weatherErrorMessage = TextLiteral.Home.failWeather } } - func loadActiveCategories() { - let allCategories = categoryUseCase.loadCategories() - print("전체 카테고리 개수: \(allCategories.count)") - - self.activeCategories = allCategories.filter { $0.itemCount > 0 } - print("활성화된 카테고리: \(activeCategories.map { $0.title })") - - let clothItems = categoryUseCase.loadClothItems() - clothItemsByCategory = Dictionary(grouping: clothItems) { $0.categoryId } - } - - // MARK: - 새로운 API 방식 (비동기) + /// API를 통해 활성 카테고리의 의류 아이템 리스트를 비동기로 가져옴 func loadActiveCategoriesWithAPI() async { - // 1. 모든 카테고리를 가져온 후 itemCount가 1 이상인 것만 필터링 let allCategories = categoryUseCase.loadCategories() let filteredCategories = allCategories.filter { $0.itemCount > 0 } - - // 2. UI에 반영될 리스트를 필터링된 것으로 교체 self.activeCategories = filteredCategories var allClothItems: [HomeClothEntity] = [] - // 3. 전체(categories)가 아닌 필터링된 리스트(filteredCategories)로 루프 실행 for category in filteredCategories { do { let items = try await categoryUseCase.loadClothItems( @@ -123,64 +145,17 @@ final class HomeViewModel: ObservableObject { } } - // 4. 결과 그룹화 clothItemsByCategory = Dictionary(grouping: allClothItems) { $0.categoryId } } +} - // MARK: - 특정 카테고리만 로드 - func loadClothItems(for categoryId: Int) async { - do { - let items = try await categoryUseCase.loadClothItems( - lastClothId: nil, - size: 20, - categoryId: Int64(categoryId), - season: nil - ) - - // 해당 카테고리의 아이템 업데이트 - clothItemsByCategory[categoryId] = items - } catch { - print("Failed to load cloth items: \(error)") - } - } - - // MARK: - 페이지네이션 (더 불러오기) - func loadMoreClothItems(for categoryId: Int) async { - guard let existingItems = clothItemsByCategory[categoryId], - let lastItem = existingItems.last else { - return - } - - do { - let newItems = try await categoryUseCase.loadClothItems( - lastClothId: Int64(lastItem.id), - size: 20, - categoryId: Int64(categoryId), - season: nil - ) - - // 기존 아이템에 추가 - clothItemsByCategory[categoryId] = existingItems + newItems - } catch { - print("Failed to load more items: \(error)") - } - } - - func loadDummyCodi() { - codiItems = todayCodiUseCase.loadTodaysCodi() - } - - func loadToday() { - let entity = dateUseCase.getToday() - self.todayString = entity.formattedDate - } +// MARK: - UI Logic & Actions +extension HomeViewModel { - // MARK: - UI Actions + /// 코디 이미지 내의 태그 표시 셀렉터를 토글 func toggleClothSelector() { withAnimation(.spring()) { showClothSelector.toggle() - - // 셀렉터가 닫힐 때(false가 될 때) 선택된 아이템 정보 초기화 if !showClothSelector { selectedItemID = nil selectedItemTags = [] @@ -188,34 +163,30 @@ final class HomeViewModel: ObservableObject { } } - func selectCloth(at index: Int) { - selectedIndex = index - } - + /// 코디판 이미지 중 특정 아이템을 선택하여 태그를 표시 func selectItem(_ id: Int?) { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { selectedItemID = id - if let id = id, let item = codiItems.first(where: { $0.id == id }) { - // 이미지 중심 좌표(item.x)가 화면 중앙보다 오른쪽인지 왼쪽인지 판단 - // (기준을 200으로 잡거나 UIScreen.main.bounds.width / 2로 설정) - let isImageOnRight = item.x > 200 - - self.selectedItemTags = [ - ClothTagEntity( - title: item.brandName, - content: item.clothName, // 또는 item.description - locationX: 0.5, - locationY: 0.5, - isRightSide: !isImageOnRight // 이미지가 오른쪽이면 태그는 왼쪽(false) - ) - ] - } else { + guard let id = id, let item = codiItems.first(where: { $0.id == id }) else { self.selectedItemTags = [] + return } + + // 이미지 위치에 따른 태그 방향 결정 (임계값 200) + let isImageOnRight = item.x > 200 + self.selectedItemTags = [ + ClothTagEntity( + title: item.brandName, + content: item.clothName, + locationX: 0.5, + locationY: 0.5, + isRightSide: !isImageOnRight + ) + ] } } - // 태그 위치 업데이트 (드래그 시 사용) + /// 드래그를 통해 태그의 상대 위치를 업데이트 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 @@ -223,62 +194,63 @@ final class HomeViewModel: ObservableObject { } } + /// 카테고리별로 선택된 의류의 인덱스를 업데이트 func updateSelectedIndex(for categoryId: Int, index: Int) { selectedIndicesByCategory[categoryId] = index } +} + +// MARK: - Navigation +extension HomeViewModel { - func handleSearchTap() {} - - func handleNotificationTap() {} - - // MARK: - Navigation + /// 코디보드 화면으로 이동 func handleCodiBoardTap() { navigationRouter.navigate(to: .codiBoard) } + /// 카테고리 편집 화면으로 이동 + func handleEditCategory() { + navigationRouter.navigate(to: .editCategory) + } + + /// 룩북으로 이동 + func selectEditCodi() { + navigationRouter.navigate(to: .lookbook) + } +} + +// MARK: - Popup & Decision Actions +extension HomeViewModel { + + /// 현재 스크롤된 의류 조합을 수집하고 완료 팝업을 띄움 func handleConfirmCodiTap() { - // 수정된 수집 로직: 저장된 인덱스를 기반으로 아이템 추출 let items = activeCategories .sorted(by: { $0.id < $1.id }) .compactMap { category -> HomeClothEntity? in guard let clothList = clothItemsByCategory[category.id] else { return nil } - - // 해당 카테고리에 저장된 인덱스가 있으면 사용, 없으면 0번째 사용 - let selectedIndex = selectedIndicesByCategory[category.id] ?? 0 - - // 배열 범위를 벗어나지 않도록 방어 코드 추가 - if clothList.indices.contains(selectedIndex) { - return clothList[selectedIndex] - } else { - return clothList.first - } + let index = selectedIndicesByCategory[category.id] ?? 0 + return clothList.indices.contains(index) ? clothList[index] : clothList.first } self.selectedCodiClothes = items self.showCompletePopUp = true } - func handleEditCategory() { - navigationRouter.navigate(to: .editCategory) - } - - // MARK: - Popup Actions + /// 코디 확정 후 완료 팝업을 표시 func showCompletionPopup(imageURL: String?) { completedCodiImageURL = imageURL showCompletePopUp = true } + /// 팝업에서 '기록하기' 버튼을 눌러 오늘 완성한 코디를 서버에 전송 func handlePopupRecord() { let containerSize: CGFloat = 260 - let payloads = selectedCodiClothes.enumerated().map { index, cloth in - let position = CodiLayoutCalculator.position( index: index, totalCount: selectedCodiClothes.count, containerSize: containerSize ) - return CodiPayload( clothId: cloth.id, locationX: position.x, @@ -295,48 +267,41 @@ final class HomeViewModel: ObservableObject { ) Task { - try await todayCodiUseCase.recordTodayCodi(todayCodi) - showCompletePopUp = false - hasCodi = true + do { + try await todayCodiUseCase.recordTodayCodi(todayCodi) + showCompletePopUp = false + hasCodi = true + } catch { + print("Failed to record today's codi: \(error)") + } } } + /// 팝업을 닫기 func handlePopupClose() { showCompletePopUp = false completedCodiImageURL = nil } +} + +// MARK: - LookBook Actions +extension HomeViewModel { - // MARK: - Lifecycle - func onAppear() { - loadActiveCategories() - } - - // MARK: - Feature Placeholders - func rememberCodi() {} - - func selectEditCodi() { - navigationRouter.navigate(to: .lookbook) - } - + /// 내 룩북 리스트를 불러와 바텀시트를 표시 func addLookbook() { - print("DEBUG: addLookbook() called") // 호출 여부 확인 Task { do { let list = try await addToLookBookUseCase.execute() self.lookBookList = list self.showLookBookSheet = true - print("DEBUG: showLookBookSheet set to true, list count: \(list.count)") } catch { - print("DEBUG: Failed to load lookbooks: \(error)") + print("Failed to load lookbooks: \(error)") } } } + /// 바텀시트에서 특정 룩북을 선택 func selectLookBook(_ entity: LookBookBottomSheetEntity) { - print("Selected LookBook ID: \(entity.lookbookId)") showLookBookSheet = false - // 추가 성공 팝업 등을 띄우는 로직으로 이어질 수 있음 } - - func sharedCodi() {} } From f9703a74a9a78411d41ddd52c300ad6469879a04 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 03:01:49 +0900 Subject: [PATCH 30/35] =?UTF-8?q?[#43]=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Features/Home/Presentation/View/HomeHasCodiView.swift | 4 ++-- .../Features/Home/Presentation/View/HomeNoCodiView.swift | 7 ++++--- Codive/Features/Home/Presentation/View/HomeView.swift | 4 +--- .../Home/Presentation/ViewModel/HomeViewModel.swift | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index 4779037b..6b88ad81 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -101,7 +101,7 @@ private extension HomeHasCodiView { /// 하단 배너 var bottomBanner: some View { CustomBanner(text: TextLiteral.Home.bannerTitle) { - viewModel.rememberCodi() +// viewModel.rememberCodi() } .padding() } @@ -113,7 +113,7 @@ private extension HomeHasCodiView { menuActions: [ { viewModel.selectEditCodi() }, { viewModel.addLookbook() }, - { viewModel.sharedCodi() } + { /*viewModel.sharedCodi()*/ } ] ) .zIndex(9999) diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index eb7bbf29..c6116f6c 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -46,7 +46,7 @@ private extension HomeNoCodiView { .padding(.top, 24) .padding(.bottom, 16) } - + /// 카테고리 편집 및 랜덤 설정 버튼 영역 var categoryButtons: some View { HStack(spacing: 8) { @@ -61,7 +61,7 @@ private extension HomeNoCodiView { .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 16) } - + /// 카테고리별 의류 리스트 (가로 스크롤 영역들) var codiClothList: some View { VStack(spacing: 16) { @@ -83,7 +83,7 @@ private extension HomeNoCodiView { } .padding(.horizontal, 20) } - + /// 하단 액션 버튼 (코디판 이동 및 코디 확정) var bottomButtons: some View { GeometryReader { geometry in @@ -130,4 +130,5 @@ private extension HomeNoCodiView { .background(Color.Codive.main0) .clipShape(RoundedRectangle(cornerRadius: 10)) } + } } diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index ea11adcf..eab50865 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -60,10 +60,8 @@ struct HomeView: View { .padding(.bottom, 16) } .id(scrollViewID) - - // 팝업 오버레이 + if viewModel.showCompletePopUp { - // 수정된 부분: imageURL 대신 selectedClothes 리스트를 전달합니다. CompletePopUp( isPresented: $viewModel.showCompletePopUp, onRecordTapped: viewModel.handlePopupRecord, diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index cd46858e..8d02747c 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -84,7 +84,7 @@ final class HomeViewModel: ObservableObject { } // MARK: - Data Loading Methods -private extension HomeViewModel { +extension HomeViewModel { /// 앱 실행 시 필요한 초기 데이터를 로드 func loadInitialData() { From d8229ce420a3ed6cab1c159e6ba19a6381c42174 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 03:05:11 +0900 Subject: [PATCH 31/35] =?UTF-8?q?[#43]=20=ED=8C=9D=EC=97=85=EC=B0=BD=20?= =?UTF-8?q?=EB=9D=84=EC=9A=B0=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Features/Home/Presentation/View/HomeView.swift | 10 ---------- Codive/Features/Main/View/MainTabView.swift | 10 ++++++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index eab50865..64d0d3f3 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -60,16 +60,6 @@ struct HomeView: View { .padding(.bottom, 16) } .id(scrollViewID) - - if viewModel.showCompletePopUp { - CompletePopUp( - isPresented: $viewModel.showCompletePopUp, - onRecordTapped: viewModel.handlePopupRecord, - onCloseTapped: viewModel.handlePopupClose, - selectedClothes: viewModel.selectedCodiClothes - ) - .zIndex(1) - } } .task { await viewModel.loadWeather(for: nil) diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index d0b360e5..3ef092e6 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -100,6 +100,16 @@ struct MainTabView: View { .zIndex(100) // 가장 높은 숫자로 설정 .transition(.move(edge: .bottom)) } + + if homeViewModel.showCompletePopUp { + CompletePopUp( + isPresented: $homeViewModel.showCompletePopUp, + onRecordTapped: homeViewModel.handlePopupRecord, + onCloseTapped: homeViewModel.handlePopupClose, + selectedClothes: homeViewModel.selectedCodiClothes + ) + .zIndex(200) + } } .navigationDestination(for: AppDestination.self) { destination in destinationView(for: destination) From 83fdb037284a354bba9e76f059880a7060741fdd Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 03:10:49 +0900 Subject: [PATCH 32/35] =?UTF-8?q?[#43]=20swiftlint=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Home/Presentation/View/HomeNoCodiView.swift | 11 +++++------ .../Home/Presentation/ViewModel/HomeViewModel.swift | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index c6116f6c..679d6716 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -71,12 +71,11 @@ private extension HomeNoCodiView { CodiClothView( title: category.title, items: clothItems, - isEmptyState: clothItems.isEmpty, - onIndexChanged: { newIndex in - // 사용자가 스크롤 할 때마다 ViewModel의 선택 인덱스 업데이트 - viewModel.updateSelectedIndex(for: category.id, index: newIndex) - } - ) + isEmptyState: clothItems.isEmpty + ) { newIndex in + // 사용자가 스크롤 할 때마다 ViewModel의 선택 인덱스 업데이트 + viewModel.updateSelectedIndex(for: category.id, index: newIndex) + } // 데이터 변경 시 뷰 갱신을 위한 식별자 지정 .id("\(category.id)-\(clothItems.count)") } diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 8d02747c..78aa3fcc 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -225,7 +225,7 @@ extension HomeViewModel { /// 현재 스크롤된 의류 조합을 수집하고 완료 팝업을 띄움 func handleConfirmCodiTap() { let items = activeCategories - .sorted(by: { $0.id < $1.id }) + .sorted { $0.id < $1.id } .compactMap { category -> HomeClothEntity? in guard let clothList = clothItemsByCategory[category.id] else { return nil } let index = selectedIndicesByCategory[category.id] ?? 0 From 58bed8ca4b6e3110ff28b7f7238862265729f401 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 13:38:54 +0900 Subject: [PATCH 33/35] =?UTF-8?q?[#43]=20entity=20type=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Features/Home/Domain/Entities/HomeEntity.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 80837a6c..92e2aa64 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -161,6 +161,6 @@ struct CodiPayload { let locationX: Double let locationY: Double let ratio: Double - let degree: Int + let degree: Double let order: Int } From a96b3920757257aadb573f44843d59b6e06f326b Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 13:41:48 +0900 Subject: [PATCH 34/35] =?UTF-8?q?[#43]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=83=9C=EA=B7=B8=EC=9C=84=EC=B9=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=86=8D=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- .../Features/Home/Presentation/ViewModel/HomeViewModel.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift index 78aa3fcc..31d3910e 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -172,15 +172,12 @@ extension HomeViewModel { return } - // 이미지 위치에 따른 태그 방향 결정 (임계값 200) - let isImageOnRight = item.x > 200 self.selectedItemTags = [ ClothTagEntity( title: item.brandName, content: item.clothName, locationX: 0.5, - locationY: 0.5, - isRightSide: !isImageOnRight + locationY: 0.5 ) ] } From 8bc72b2c6d2b8c1e30a19c4be8caf0a7166269d2 Mon Sep 17 00:00:00 2001 From: Funital Date: Fri, 16 Jan 2026 13:44:45 +0900 Subject: [PATCH 35/35] =?UTF-8?q?[#43]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20entity=EC=9D=98=20=EC=86=8D=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EA=B3=A0=EC=9C=A0=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 --- Codive/Features/Home/Domain/Entities/HomeEntity.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Codive/Features/Home/Domain/Entities/HomeEntity.swift b/Codive/Features/Home/Domain/Entities/HomeEntity.swift index 92e2aa64..c014d911 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -133,12 +133,17 @@ struct CodiCoordinatePayloadDTO: Codable { // MARK: - LookBook BottomSheet Entity struct LookBookBottomSheetEntity: Identifiable, Codable { - var id = UUID() let lookbookId: Int let codiId: Int let imageUrl: String let title: String let count: Int + + var id: Int { lookbookId } + + private enum CodingKeys: String, CodingKey { + case lookbookId, codiId, imageUrl, title, count + } } // MARK: - Cloth Tag Entity @@ -148,7 +153,7 @@ struct ClothTagEntity: Identifiable, Hashable { let content: String var locationX: CGFloat var locationY: CGFloat - var isRightSide: Bool = true +// var isRightSide: Bool = true } struct TodayDailyCodi {