diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index c772aa3c..dddecf84 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -135,9 +135,15 @@ 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맞게 코디 해봐요." + static let popUpTitle = "오늘의 코디 완성!" + static let popUpSubtitle = "오늘의 코디는 홈 화면에서 하루 동안만 유지\n됩니다. 추가로 기록하려면 피드에 남겨보세요!" + static let close = "닫기" + static let record = "기록하기" } enum Search { diff --git a/Codive/DIContainer/HomeDIContainer.swift b/Codive/DIContainer/HomeDIContainer.swift index 5be1489d..5b38a29b 100644 --- a/Codive/DIContainer/HomeDIContainer.swift +++ b/Codive/DIContainer/HomeDIContainer.swift @@ -21,38 +21,90 @@ final class HomeDIContainer { let locationService: LocationService = SystemLocationService() - lazy var homeDatasource = HomeDatasource(locationService: locationService) + // HomeViewModel 싱글톤 인스턴스 저장 + private var homeViewModel: HomeViewModel? - lazy var homeRepository: HomeRepository = HomeRepositoryImpl( - dataSource: homeDatasource - ) + // MARK: - DataSources + private lazy var homeDatasource: HomeDatasource = { + HomeDatasource(locationService: locationService) + }() + + // MARK: - Repositories + private lazy var homeRepository: HomeRepository = { + HomeRepositoryImpl(dataSource: homeDatasource) + }() + + // MARK: - UseCases (각 기능별) + + func makeFetchWeatherUseCase() -> FetchWeatherUseCase { + FetchWeatherUseCase(repository: homeRepository) + } - lazy var homeUseCase = HomeUseCase(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) + } + + func makeAddToLookBookUseCase() -> AddToLookBookUseCase { + AddToLookBookUseCase(repository: homeRepository) + } // MARK: - Initializer init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter } + // MARK: - ViewModels + func makeHomeViewModel() -> HomeViewModel { - return HomeViewModel( + if let existingViewModel = homeViewModel { + return existingViewModel + } + + let viewModel = HomeViewModel( navigationRouter: navigationRouter, - useCase: homeUseCase + fetchWeatherUseCase: makeFetchWeatherUseCase(), + todayCodiUseCase: makeTodayCodiUseCase(), + dateUseCase: makeDateUseCase(), + categoryUseCase: makeCategoryUseCase(), + addToLookBookUseCase: makeAddToLookBookUseCase() ) + + homeViewModel = viewModel + return viewModel } func makeEditCategoryViewModel() -> EditCategoryViewModel { - return EditCategoryViewModel(navigationRouter: navigationRouter) + return EditCategoryViewModel( + navigationRouter: navigationRouter + ) } func makeCodiBoardViewModel() -> CodiBoardViewModel { return CodiBoardViewModel( - navigationRouter: navigationRouter, useCase: homeUseCase + navigationRouter: navigationRouter, + codiBoardUseCase: makeCodiBoardUseCase(), + homeViewModel: homeViewModel ) } + // MARK: - Views + func makeEditCategoryView() -> EditCategoryView { - return EditCategoryView(viewModel: makeEditCategoryViewModel()) + return EditCategoryView( + viewModel: makeEditCategoryViewModel() + ) } func makeCodiBoardView() -> CodiBoardView { diff --git a/Codive/Features/Home/Data/DataSources/HomeDatasource.swift b/Codive/Features/Home/Data/DataSources/HomeDatasource.swift index 4e6b9cf0..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 @@ -95,14 +97,85 @@ final class HomeDatasource { currentTemp: currentTemp, symbolName: symbolName, dailyForecasts: Array(dailyForecasts), - // MARK: - 수정: 위치 이름을 추가 locationName: locationName ) return weatherData } - // MARK: - Categories + // MARK: - 코디가 없는 경우의 Home 관련 + + /// 홈화면 - 카테고리 별 옷 더미 list + func fetchClothItems(request: ClothListRequestDTO) async throws -> [ClothListResponseDTO] { + + let categoryId = request.categoryId ?? 1 + let mockResponse: [ClothListResponseDTO] + + 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 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 = [] + } + + return mockResponse + } + + // 오늘의 코디 추가하기 + 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: - Categorory 수정 뷰 관련 + /// 카테고리 별 개수 func loadCategories() -> [CategoryEntity] { if let savedCategories = UserDefaults.standard.data(forKey: "SavedCategories"), let decoded = try? JSONDecoder().decode([CategoryEntity].self, from: savedCategories) { @@ -110,7 +183,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), @@ -120,6 +193,7 @@ final class HomeDatasource { ] } + /// 카테고리 적용하기 func saveCategories(_ categories: [CategoryEntity]) { if let encoded = try? JSONEncoder().encode(categories) { UserDefaults.standard.set(encoded, forKey: "SavedCategories") @@ -127,8 +201,25 @@ final class HomeDatasource { print("저장 완료:") categories.forEach { print("\($0.id): \($0.title): \($0.itemCount)") } } + + // MARK: - 코디보드 + // 코디 추가하기 + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { + try await Task.sleep(nanoseconds: 500_000_000) + + for (index, item) in request.Payload.enumerated() { + print(""" + [Item \(index)] + - clothId: \(item.clothId) + - position: (\(item.locationX), \(item.locationY)) + - ratio(scale): \(item.ratio) + - degree: \(item.degree) + - order: \(item.order) + """) + } + } - // MARK: - Codi Items + // 코디보드 옷 불러오기 func loadInitialImages() -> [DraggableImageEntity] { return [ DraggableImageEntity(id: 1, name: "image1", position: CGPoint(x: 80, y: 80), scale: 1.0, rotationAngle: 0.0), @@ -140,28 +231,47 @@ 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) - } - } - + // MARK: - 코디가 있는 경우의 Home 관련 + // 코디 불러오기 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: 1, + imageName: "image1", + clothName: "시계", + brandName: "apple", + description: "사계절 착용 가능한 시계", + x: 300, + y: 100, + width: 70, + height: 70 + ), + CodiItemEntity( + id: 2, + imageName: "image4", + clothName: "체크 셔츠", + brandName: "Polo", + description: "사계절 착용 가능한 셔츠", + x: 100, + y: 100, + width: 70, + height: 70 + ), + CodiItemEntity( + id: 3, + imageName: "image3", + clothName: "와이드 치노 팬츠", + brandName: "Basic Concept", + description: "사계절 착용 가능한 면 바지", + x: 300, + y: 200, + width: 100, + height: 100 + ) ] } - - // MARK: - Date Handling + + // 오늘의 날짜 func fetchToday() -> DateEntity { let formatter = DateFormatter() formatter.dateFormat = "MM.dd" @@ -169,4 +279,15 @@ final class HomeDatasource { let todayString = formatter.string(from: Date()) return DateEntity(formattedDate: todayString) } + + // 룩북에 추가 바텀시트 더미데이터 + 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 fbfed271..9d31dcd0 100644 --- a/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift +++ b/Codive/Features/Home/Data/Repositories/HomeRepositoryImpl.swift @@ -16,35 +16,55 @@ 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 fetchClothItems(request: ClothListRequestDTO) async throws -> [HomeClothEntity] { + let dtoList = try await dataSource.fetchClothItems(request: request) + + let categoryId = Int(request.categoryId ?? 1) + return dtoList.toEntities(categoryId: categoryId) } - func saveCategories(_ categories: [CategoryEntity]) { - dataSource.saveCategories(categories) + func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws { + try await dataSource.createTodayDailyCodi(codi) } - // MARK: - Codi Items + // MARK: - 코디보드 + func fetchInitialImages() -> [DraggableImageEntity] { dataSource.loadInitialImages() } - func saveCodiItems(_ images: [DraggableImageEntity]) { - dataSource.saveCodiItems(images) + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws { + try await dataSource.saveCodiCoordinate(request) } + // MARK: - 코디가 있는 경우의 Home 관련 + func fetchCodiItems() -> [CodiItemEntity] { dataSource.loadDummyCodiItems() } - // MARK: - Date func getToday() -> DateEntity { dataSource.fetchToday() } + + func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] { + return try await dataSource.fetchLookBookList() + } + + // 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 aa212d4f..c014d911 100644 --- a/Codive/Features/Home/Domain/Entities/HomeEntity.swift +++ b/Codive/Features/Home/Domain/Entities/HomeEntity.swift @@ -32,19 +32,30 @@ 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 let name: String var position: CGPoint var scale: CGFloat - var rotationAngle: Double + var rotationAngle: Double + let imageURL: String? = nil } // MARK: - Codi Item 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 @@ -55,3 +66,106 @@ 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) } + } +} + +// 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: - LookBook BottomSheet Entity +struct LookBookBottomSheetEntity: Identifiable, Codable { + 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 +struct ClothTagEntity: Identifiable, Hashable { + let id = UUID() + let title: String + let content: String + var locationX: CGFloat + var locationY: CGFloat +// 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: Double + let order: Int +} diff --git a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift index 426f4f9e..30bad1a2 100644 --- a/Codive/Features/Home/Domain/Protocols/HomeRepository.swift +++ b/Codive/Features/Home/Domain/Protocols/HomeRepository.swift @@ -8,20 +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 관련 + + func fetchClothItems(request: ClothListRequestDTO) async throws -> [HomeClothEntity] + func createTodayDailyCodi(_ codi: TodayDailyCodi) async throws + + // MARK: - 코디보드 - // MARK: - Initial Images func fetchInitialImages() -> [DraggableImageEntity] + func saveCodiCoordinate(_ request: CodiCoordinateRequestDTO) async throws - // MARK: - Codi Items - func saveCodiItems(_ images: [DraggableImageEntity]) - func fetchCodiItems() -> [CodiItemEntity] + // MARK: - 코디가 있는 경우의 Home 관련 - // MARK: - Date + func fetchCodiItems() -> [CodiItemEntity] func getToday() -> DateEntity + func fetchLookBookList() async throws -> [LookBookBottomSheetEntity] + + // 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 new file mode 100644 index 00000000..2147b567 --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/AddToLookBookUseCase.swift @@ -0,0 +1,20 @@ +// +// 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/Domain/UseCases/CategoryUseCase.swift b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift new file mode 100644 index 00000000..5d83092a --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/CategoryUseCase.swift @@ -0,0 +1,39 @@ +// +// 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 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/Domain/UseCases/CodiBoardUseCase.swift b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift new file mode 100644 index 00000000..7b32922c --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/CodiBoardUseCase.swift @@ -0,0 +1,43 @@ +// +// CodiBoardUseCase.swift +// Codive +// +// Created by 한금준 on 12/25/25. +// + +import Foundation + +final class CodiBoardUseCase { + + private let repository: HomeRepository + + init(repository: HomeRepository) { + self.repository = repository + } + + func loadCodiBoardImages() -> [DraggableImageEntity] { + return repository.fetchInitialImages() + } + + func saveCodiItems(_ images: [DraggableImageEntity]) async throws { + let mockSnapshotUrl = "https://codive-storage.com/previews/\(UUID().uuidString).jpg" + + let payloads: [CodiCoordinatePayloadDTO] = images.enumerated().map { index, image in + return CodiCoordinatePayloadDTO( + clothId: Int64(image.id), + locationX: Double(image.position.x), + locationY: Double(image.position.y), + ratio: Double(image.scale), + degree: Double(image.rotationAngle), + order: index + ) + } + + let request = CodiCoordinateRequestDTO( + coordinateImageUrl: mockSnapshotUrl, + Payload: payloads + ) + + try await repository.saveCodiCoordinate(request) + } +} 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..c50e9fb9 --- /dev/null +++ b/Codive/Features/Home/Domain/UseCases/TodayCodiUseCase.swift @@ -0,0 +1,23 @@ +// +// 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() + } + + func recordTodayCodi(_ codi: TodayDailyCodi) async throws { + try await repository.createTodayDailyCodi(codi) + } +} diff --git a/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift new file mode 100644 index 00000000..e3cf2302 --- /dev/null +++ b/Codive/Features/Home/Presentation/Component/AddBottomSheet.swift @@ -0,0 +1,224 @@ +// +// AddBottomSheet.swift +// Codive +// +// Created by 한금준 on 12/26/25. +// + +import SwiftUI + +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 + + private func dismiss() { + withAnimation { isPresented = false } + onDismiss?() + translationY = 0 + } +} + +// MARK: - LookBook Card View + +struct LookBookSheetCardView: View { + let entity: LookBookBottomSheetEntity + let thumbnail: Thumbnail? + let onTap: (() -> Void)? + + // MARK: - Body + + 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) + } + + 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] + let thumbnailProvider: (LookBookBottomSheetEntity) -> Thumbnail? + let onTapEntity: (LookBookBottomSheetEntity) -> Void + + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + // MARK: - Body + + 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) + ) { + onTapEntity(entity) + } + } + } + .padding(.vertical, 8) + } + } + } +} diff --git a/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift b/Codive/Features/Home/Presentation/Component/CategoryCounterView.swift index b807e7f9..3aca0e40 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() + // 감소 버튼: 이제 상의/하의 등도 isEmpty가 아니면(1이면) 0으로 줄일 수 있습니다. 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) + // 증가 버튼 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) @@ -71,20 +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 - ) - .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 73ff8fb2..bf7e20eb 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -7,133 +7,258 @@ import SwiftUI -struct ClothItem: Identifiable { - let id = UUID() - let color: Color -} +// MARK: - Card (단일 슬롯) struct ClothCardView: View { - let item: ClothItem + let item: HomeClothEntity let width: CGFloat 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) + .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) } } } -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: [HomeClothEntity] + @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)) - - 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) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .background { + Color.Codive.grayscale7 + } } + .frame(height: 148) + .clipShape(RoundedRectangle(cornerRadius: 16)) } } -#Preview { - CodiClothView(title: "바지") - .padding(.horizontal, 20) +// MARK: - Entry View + +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 + private let inactiveScale: CGFloat = 0.9 + + @State private var currentIndex: Int + + init(title: String, + items: [HomeClothEntity] = [], + 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) + } + + var body: some View { + HStack(spacing: 8) { + ZStack(alignment: .topLeading) { + CodiClothCarouselView( + items: items, + currentIndex: $currentIndex, + spacing: spacing, + activeScale: activeScale, + inactiveScale: inactiveScale, + isEmptyState: isEmptyState + ) + // 인덱스가 바뀔 때마다 외부로 알려줌 + .onChange(of: currentIndex) { newValue in + onIndexChanged?(newValue) + } + + 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) + } + + Image("move") + .resizable() + .scaledToFit() + .frame(width: 11) + } + } } 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 new file mode 100644 index 00000000..943e0c90 --- /dev/null +++ b/Codive/Features/Home/Presentation/Component/CompletePopUp.swift @@ -0,0 +1,96 @@ +// +// 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 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: 260, height: 260) + .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)) + } +} + +// MARK: - 합성 레이아웃 뷰 +struct CodiCompositeView: View { + let clothes: [HomeClothEntity] + // 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.. 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 4475a95a..e9b04622 100644 --- a/Codive/Features/Home/Presentation/View/EditCategoryView.swift +++ b/Codive/Features/Home/Presentation/View/EditCategoryView.swift @@ -8,72 +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)/10)") - .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 - ) - } - } - .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 1ec443e5..6b88ad81 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 } } +} - private var header: some View { +// MARK: - View Components +private extension HomeHasCodiView { + + /// 상단 헤더 (오늘의 코디 제목 및 날짜) + var header: some View { HStack { Text("\(TextLiteral.Home.todayCodiTitle)(\(viewModel.todayString))") .font(.title2) @@ -49,39 +46,40 @@ struct HomeHasCodiView: View { } } - 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) + /// 코디 아이템 및 태그가 표시되는 메인 캔버스 영역 + var codiDisplayArea: some View { + GeometryReader { canvasProxy in + let canvasSize = canvasProxy.size + + ZStack(alignment: .bottomLeading) { + // 배경 레이어 + boardBackground(size: canvasSize) - ForEach(viewModel.codiItems) { item in - Image(item.imageName) - .resizable() - .scaledToFit() - .frame(width: item.width, height: item.height) - .position(x: item.x, y: item.y) - } + // 이미지 아이템 레이어 + ForEach(viewModel.codiItems) { item in + Image(item.imageName) + .resizable() + .scaledToFit() + .frame(width: item.width, height: item.height) + .position(x: item.x, y: item.y) + } + + // 선택된 아이템의 태그 레이어 + if let selectedID = viewModel.selectedItemID, + let selectedItem = viewModel.codiItems.first(where: { $0.id == selectedID }) { + tagOverlay(for: selectedItem, in: canvasSize) + } - Button(action: viewModel.toggleClothSelector) { - Image("ic_tag") - .resizable() - .frame(width: 28, height: 28) + // 태그 셀렉터 토글 버튼 + tagToggleButton } - .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 { + + /// 하단 의류 선택 스크롤 뷰 + var clothSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(viewModel.codiItems) { item in @@ -89,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) } ) ) @@ -103,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 918639e2..679d6716 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -8,85 +8,126 @@ 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() - bottomButtons + + if !viewModel.isAllCategoriesEmpty { + bottomButtons + } } .onAppear { 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 - CodiClothView(title: category.title) + let clothItems = viewModel.clothItemsByCategory[category.id] ?? [] + + CodiClothView( + title: category.title, + items: clothItems, + isEmptyState: clothItems.isEmpty + ) { newIndex in + // 사용자가 스크롤 할 때마다 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/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index eae62aad..64d0d3f3 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -10,20 +10,26 @@ 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 { GeometryReader { outerGeometry in - VStack(spacing: 0) { + ZStack { + // 전체 배경 + Color.white + .ignoresSafeArea() + ScrollView { VStack { + // 날씨 카드 if let weather = viewModel.weatherData { WeatherCardView(weatherData: weather) .padding(.horizontal, 20) @@ -41,6 +47,7 @@ struct HomeView: View { } } + // 코디 여부에 따라 다른 뷰 if viewModel.hasCodi { HomeHasCodiView( viewModel: viewModel, @@ -50,17 +57,19 @@ struct HomeView: View { HomeNoCodiView(viewModel: viewModel) } } + .padding(.bottom, 16) } - } - .background(alignment: .center) { - Color.white + .id(scrollViewID) } .task { await viewModel.loadWeather(for: nil) + await viewModel.loadActiveCategoriesWithAPI() } .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 7683ac9a..4f560424 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -11,56 +11,98 @@ import SwiftUI final class CodiBoardViewModel: ObservableObject, DraggableImageViewModelProtocol { // MARK: - Properties + @Published var isConfirmed: Bool = false @Published var images: [DraggableImageEntity] = [] @Published var currentlyDraggedID: Int? - private let useCase: HomeUseCase + private let codiBoardUseCase: CodiBoardUseCase private let navigationRouter: NavigationRouter + private weak var homeViewModel: HomeViewModel? // MARK: - Initializer - init(navigationRouter: NavigationRouter, useCase: HomeUseCase) { + + init( + navigationRouter: NavigationRouter, + codiBoardUseCase: CodiBoardUseCase, + homeViewModel: HomeViewModel? = nil + ) { self.navigationRouter = navigationRouter - self.useCase = useCase + self.codiBoardUseCase = codiBoardUseCase + self.homeViewModel = homeViewModel + loadInitialData() } - // MARK: - Data Loading + // MARK: - Private Methods + + /// 초기 코디판 이미지 데이터를 로드 private func loadInitialData() { - images = useCase.loadCodiBoardImages() + self.images = codiBoardUseCase.loadCodiBoardImages() } // MARK: - Navigation + + /// 이전 화면으로 이동 func handleBackTap() { navigationRouter.navigateBack() } // MARK: - Actions + + /// 구성된 코디를 서버에 저장하고 홈 화면의 완료 팝업을 띄움 func handleConfirmCodi() { - useCase.saveCodiItems(images) - isConfirmed = true + Task { + do { + // 1. 서버에 데이터 전송 + try await codiBoardUseCase.saveCodiItems(images) + + // 2. 저장 성공 후 UI 처리 (MainActor에서 실행됨) + let imageURL = images.first?.imageURL + navigationRouter.navigateBack() + + // 홈 화면으로 돌아가는 애니메이션 시간을 고려하여 지연 실행 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.homeViewModel?.showCompletionPopup(imageURL: imageURL) + } + + self.isConfirmed = true + } catch { + handleError(error) + } + } } - // 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 00f8a00e..620784bc 100644 --- a/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/EditCategoryViewModel.swift @@ -10,88 +10,132 @@ import SwiftUI @MainActor final class EditCategoryViewModel: ObservableObject { - // MARK: - Properties - private let navigationRouter: NavigationRouter - var totalCount: Int { categories.reduce(0) { $0 + $1.itemCount } } + // MARK: - Properties (State & Storage) - var hasChanges: Bool { - return categories.map { $0.itemCount } != initialCategories.map { $0.itemCount } - } - - var isApplyButtonEnabled: Bool { - return hasChanges && totalCount > 0 - } - - @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: 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) ] + // 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) { - 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 + let decoded = try? JSONDecoder().decode([CategoryEntity].self, from: data) { + + // 데이터 로드 시 고정 항목 규칙 적용 + self.categories = decoded.map { category in + var updated = category + if isFixed(category: category) { + updated.itemCount = 1 } + return updated } - self.categories = defaultCategories + } else { + // 저장된 데이터가 없는 경우 기본값 사용 + self.categories = Self.allCategories + saveToStorage(categories: Self.allCategories) } + + // 초기 비교를 위한 상태 백업 self.initialCategories = self.categories } - // MARK: - Category Count Handling + /// 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) + } + + /// 카테고리 아이템 개수 증가 (최대 1개, 전체 7개 제한) func incrementCount(for category: CategoryEntity) { 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 let index = categories.firstIndex(where: { $0.id == category.id }) else { return } + 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 @@ -100,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 b8739775..31d3910e 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel.swift @@ -6,126 +6,299 @@ // import SwiftUI -import UIKit import Combine import CoreLocation @MainActor final class HomeViewModel: ObservableObject { - // MARK: - Properties - @Published var hasCodi: Bool = true - @Published var selectedIndex: Int? = 0 + // MARK: - Properties (UI State) + + @Published var hasCodi: Bool = false @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 selectedIndicesByCategory: [Int: Int] = [:] + @Published var selectedCodiClothes: [HomeClothEntity] = [] - @AppStorage("SavedCategories") private var savedCategoriesData: Data? + // MARK: - Dependencies let navigationRouter: NavigationRouter - private let useCase: HomeUseCase + 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 { + let totalItemCount = activeCategories.reduce(0) { sum, category in + sum + (clothItemsByCategory[category.id]?.count ?? 0) + } + return totalItemCount == 0 + } // MARK: - Initializer - init(navigationRouter: NavigationRouter, useCase: HomeUseCase) { + + init( + navigationRouter: NavigationRouter, + fetchWeatherUseCase: FetchWeatherUseCase, + todayCodiUseCase: TodayCodiUseCase, + dateUseCase: DateUseCase, + categoryUseCase: CategoryUseCase, + addToLookBookUseCase: AddToLookBookUseCase + ) { self.navigationRouter = navigationRouter - self.useCase = useCase + self.fetchWeatherUseCase = fetchWeatherUseCase + self.todayCodiUseCase = todayCodiUseCase + self.dateUseCase = dateUseCase + self.categoryUseCase = categoryUseCase + self.addToLookBookUseCase = addToLookBookUseCase + loadInitialData() + } + + // MARK: - Life Cycle + + func onAppear() { + loadActiveCategories() + } +} + +// MARK: - Data Loading Methods +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 useCase.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: [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) - ] - } + /// API를 통해 활성 카테고리의 의류 아이템 리스트를 비동기로 가져옴 + func loadActiveCategoriesWithAPI() async { + let allCategories = categoryUseCase.loadCategories() + let filteredCategories = allCategories.filter { $0.itemCount > 0 } + self.activeCategories = filteredCategories + + var allClothItems: [HomeClothEntity] = [] - activeCategories = allCategories.flatMap { category in - Array(repeating: category, count: category.itemCount) + for category in filteredCategories { + 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 items for category \(category.id): \(error)") + } } + + clothItemsByCategory = Dictionary(grouping: allClothItems) { $0.categoryId } } - - func loadDummyCodi() { - codiItems = useCase.loadTodaysCodi() - } +} - func loadToday() { - let entity = useCase.getToday() - self.todayString = entity.formattedDate - } +// MARK: - UI Logic & Actions +extension HomeViewModel { - // MARK: - UI Actions + /// 코디 이미지 내의 태그 표시 셀렉터를 토글 func toggleClothSelector() { withAnimation(.spring()) { showClothSelector.toggle() + if !showClothSelector { + selectedItemID = nil + selectedItemTags = [] + } } } - func selectCloth(at index: Int) { - selectedIndex = index - } - + /// 코디판 이미지 중 특정 아이템을 선택하여 태그를 표시 func selectItem(_ id: Int?) { - selectedItemID = id + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedItemID = id + guard let id = id, let item = codiItems.first(where: { $0.id == id }) else { + self.selectedItemTags = [] + return + } + + self.selectedItemTags = [ + ClothTagEntity( + title: item.brandName, + content: item.clothName, + locationX: 0.5, + locationY: 0.5 + ) + ] + } + } + + /// 드래그를 통해 태그의 상대 위치를 업데이트 + func updateTagPosition(tagId: UUID, x: CGFloat, y: CGFloat, imageSize: CGSize) { + if let index = selectedItemTags.firstIndex(where: { $0.id == tagId }) { + selectedItemTags[index].locationX = x / imageSize.width + selectedItemTags[index].locationY = y / imageSize.height + } } - func handleSearchTap() {} - - func handleNotificationTap() {} + /// 카테고리별로 선택된 의류의 인덱스를 업데이트 + func updateSelectedIndex(for categoryId: Int, index: Int) { + selectedIndicesByCategory[categoryId] = index + } +} + +// MARK: - Navigation +extension HomeViewModel { - // MARK: - Navigation + /// 코디보드 화면으로 이동 func handleCodiBoardTap() { navigationRouter.navigate(to: .codiBoard) } - func handleConfirmCodiTap() { - } - + /// 카테고리 편집 화면으로 이동 func handleEditCategory() { navigationRouter.navigate(to: .editCategory) } - // MARK: - Lifecycle - func onAppear() { - loadActiveCategories() + /// 룩북으로 이동 + func selectEditCodi() { + navigationRouter.navigate(to: .lookbook) + } +} + +// MARK: - Popup & Decision Actions +extension HomeViewModel { + + /// 현재 스크롤된 의류 조합을 수집하고 완료 팝업을 띄움 + func handleConfirmCodiTap() { + let items = activeCategories + .sorted { $0.id < $1.id } + .compactMap { category -> HomeClothEntity? in + guard let clothList = clothItemsByCategory[category.id] else { return nil } + let index = selectedIndicesByCategory[category.id] ?? 0 + return clothList.indices.contains(index) ? clothList[index] : clothList.first + } + + self.selectedCodiClothes = items + self.showCompletePopUp = true } - // MARK: - Feature Placeholders - func rememberCodi() {} + /// 코디 확정 후 완료 팝업을 표시 + 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, + locationY: position.y, + ratio: 1.0, + degree: 0, + order: index + ) + } + + let todayCodi = TodayDailyCodi( + coordinateImageUrl: completedCodiImageURL ?? "", + payloads: payloads + ) + + Task { + do { + try await todayCodiUseCase.recordTodayCodi(todayCodi) + showCompletePopUp = false + hasCodi = true + } catch { + print("Failed to record today's codi: \(error)") + } + } + } - func selectEditCodi() {} + /// 팝업을 닫기 + func handlePopupClose() { + showCompletePopUp = false + completedCodiImageURL = nil + } +} + +// MARK: - LookBook Actions +extension HomeViewModel { + /// 내 룩북 리스트를 불러와 바텀시트를 표시 func addLookbook() { - navigationRouter.navigate(to: .lookbook) + Task { + do { + let list = try await addToLookBookUseCase.execute() + self.lookBookList = list + self.showLookBookSheet = true + } catch { + print("Failed to load lookbooks: \(error)") + } + } } - func sharedCodi() {} + /// 바텀시트에서 특정 룩북을 선택 + func selectLookBook(_ entity: LookBookBottomSheetEntity) { + showLookBookSheet = false + } } 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, diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 61b360a7..3ef092e6 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,39 @@ 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)) + } + + if homeViewModel.showCompletePopUp { + CompletePopUp( + isPresented: $homeViewModel.showCompletePopUp, + onRecordTapped: homeViewModel.handlePopupRecord, + onCloseTapped: homeViewModel.handlePopupClose, + selectedClothes: homeViewModel.selectedCodiClothes + ) + .zIndex(200) } } .navigationDestination(for: AppDestination.self) { destination in