diff --git a/Codive/Application/AppDelegate.swift b/Codive/Application/AppDelegate.swift new file mode 100644 index 00000000..21eb165a --- /dev/null +++ b/Codive/Application/AppDelegate.swift @@ -0,0 +1,154 @@ +// +// AppDelegate.swift +// Codive +// +// Created by Claude on 4/26/26. +// + +import UIKit +import FirebaseMessaging +import UserNotifications + +final class AppDelegate: NSObject, UIApplicationDelegate { + + /// 앱이 죽어있을 때 푸시 탭으로 실행된 경우 저장해두는 pending 데이터 + static var pendingPushUserInfo: [AnyHashable: Any]? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + Messaging.messaging().delegate = self + + requestNotificationPermission(application) + + return true + } + + // MARK: - APNs Token + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Messaging.messaging().apnsToken = deviceToken + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + #if DEBUG + print("[Push] APNs 등록 실패: \(error.localizedDescription)") + #endif + } + + // MARK: - Permission + + private func requestNotificationPermission(_ application: UIApplication) { + let options: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization(options: options) { granted, error in + #if DEBUG + if let error { + print("[Push] 권한 요청 실패: \(error.localizedDescription)") + } else { + print("[Push] 알림 권한 허용: \(granted)") + } + #endif + + guard granted else { return } + + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension AppDelegate: UNUserNotificationCenterDelegate { + + // 포그라운드에서 알림 수신 시 배너 표시 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .badge, .sound]) + } + + // 알림 탭 시 처리 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + + #if DEBUG + print("[Push] 알림 탭 userInfo: \(userInfo)") + #endif + + // MainTabView가 아직 없으면 pending으로 저장, 있으면 바로 전달 + AppDelegate.pendingPushUserInfo = userInfo + + NotificationCenter.default.post( + name: .pushNotificationTapped, + object: nil, + userInfo: userInfo + ) + + completionHandler() + } +} + +// MARK: - MessagingDelegate + +extension AppDelegate: MessagingDelegate { + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let fcmToken else { return } + + #if DEBUG + AppLog.push.debug("FCM 토큰: \(fcmToken.masked(), privacy: .public)") + #endif + + // UserDefaults에 저장 (로그인 후 서버 전송용) + UserDefaults.standard.set(fcmToken, forKey: "fcmToken") + + // 로그인 상태면 서버에 즉시 전송 + Task { + await sendFCMTokenToServerIfNeeded(fcmToken) + } + } + + @MainActor + private func sendFCMTokenToServerIfNeeded(_ fcmToken: String) async { + let tokenService = TokenService() + + guard tokenService.hasValidTokens(), + !tokenService.isAccessTokenExpired() else { + return + } + + do { + let authAPIService = AuthAPIService() + try await authAPIService.renewDeviceToken(deviceToken: fcmToken) + #if DEBUG + print("[Push] 서버에 FCM 토큰 전송 완료") + #endif + } catch { + #if DEBUG + print("[Push] 서버 FCM 토큰 전송 실패: \(error.localizedDescription)") + #endif + } + } +} + +// MARK: - Notification Name + +extension Notification.Name { + static let pushNotificationTapped = Notification.Name("pushNotificationTapped") +} diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 2eb50855..130bb237 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -58,6 +58,9 @@ struct AppRootView: View { .scaleEffect(1.5) } } + .onTapGesture { + hideKeyboard() + } .onOpenURL { url in handleDeepLink(url: url) } diff --git a/Codive/Application/CodiveApp.swift b/Codive/Application/CodiveApp.swift index c1d53904..06e3c3c9 100644 --- a/Codive/Application/CodiveApp.swift +++ b/Codive/Application/CodiveApp.swift @@ -11,6 +11,7 @@ import KakaoSDKAuth @main struct CodiveApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate let appDIContainer = AppDIContainer() init() { diff --git a/Codive/Codive.Release.entitlements b/Codive/Codive.Release.entitlements new file mode 100644 index 00000000..82711b3d --- /dev/null +++ b/Codive/Codive.Release.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.applesignin + + Default + + com.apple.developer.weatherkit + + aps-environment + production + + diff --git a/Codive/Codive.entitlements b/Codive/Codive.entitlements index 0693e8b5..3099c187 100644 --- a/Codive/Codive.entitlements +++ b/Codive/Codive.entitlements @@ -8,5 +8,7 @@ com.apple.developer.weatherkit + aps-environment + development diff --git a/Codive/Core/Utils/Logger.swift b/Codive/Core/Utils/Logger.swift index e69de29b..19ae3027 100644 --- a/Codive/Core/Utils/Logger.swift +++ b/Codive/Core/Utils/Logger.swift @@ -0,0 +1,32 @@ +// +// Logger.swift +// Codive +// +// os.Logger 기반 통합 로깅 래퍼. +// Release 빌드에서는 .debug 레벨이 자동 무시되며, +// 민감 정보(토큰 등)는 `masked()` 헬퍼로 마스킹하여 기록한다. +// + +import Foundation +import OSLog + +enum AppLog { + private static let subsystem = "com.codive.app" + + static let app = Logger(subsystem: subsystem, category: "app") + static let auth = Logger(subsystem: subsystem, category: "auth") + static let network = Logger(subsystem: subsystem, category: "network") + static let api = Logger(subsystem: subsystem, category: "api") + static let push = Logger(subsystem: subsystem, category: "push") + static let deeplink = Logger(subsystem: subsystem, category: "deeplink") + static let ui = Logger(subsystem: subsystem, category: "ui") +} + +extension String { + /// 민감 문자열의 앞 일부만 노출하고 나머지는 마스킹. + /// - Parameter visible: 앞에서 그대로 보여줄 문자 수 (기본 8) + func masked(visible: Int = 8) -> String { + guard count > visible else { return "***" } + return prefix(visible) + "...(\(count))" + } +} diff --git a/Codive/DIContainer/AddDIContainer.swift b/Codive/DIContainer/AddDIContainer.swift index 8384de23..7184d126 100644 --- a/Codive/DIContainer/AddDIContainer.swift +++ b/Codive/DIContainer/AddDIContainer.swift @@ -19,6 +19,9 @@ final class AddDIContainer { let navigationRouter: NavigationRouter lazy var addViewFactory = AddViewFactory(addDIContainer: self) + // 캘린더에서 선택한 날짜 (기록 생성 시 사용) + var selectedDate: Date? + // 지우개 편집 시 공유 참조 weak var activeClothAddViewModel: ClothAddViewModel? var pendingErasedImage: UIImage? @@ -46,10 +49,11 @@ final class AddDIContainer { ) } - func makeRecordDetailViewModel(selectedPhotos: [SelectedPhoto]) -> RecordDetailViewModel { + func makeRecordDetailViewModel(selectedPhotos: [SelectedPhoto], selectedDate: Date? = nil) -> RecordDetailViewModel { return RecordDetailViewModel( selectedPhotos: selectedPhotos, - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + selectedDate: selectedDate ) } @@ -93,9 +97,9 @@ final class AddDIContainer { return RecordAddView(viewModel: makeRecordAddViewModel(flowType: flowType)) } - func makeRecordDetailView(selectedPhotos: [SelectedPhoto]) -> RecordDetailView { + func makeRecordDetailView(selectedPhotos: [SelectedPhoto], selectedDate: Date? = nil) -> RecordDetailView { return RecordDetailView( - viewModel: makeRecordDetailViewModel(selectedPhotos: selectedPhotos) + viewModel: makeRecordDetailViewModel(selectedPhotos: selectedPhotos, selectedDate: selectedDate) ) } diff --git a/Codive/DIContainer/AuthDIContainer.swift b/Codive/DIContainer/AuthDIContainer.swift index 62880406..2f313d1b 100644 --- a/Codive/DIContainer/AuthDIContainer.swift +++ b/Codive/DIContainer/AuthDIContainer.swift @@ -37,7 +37,8 @@ final class AuthDIContainer { return OnboardingViewModel( appRouter: appRouter, navigationRouter: navigationRouter, - authRepository: authRepository + authRepository: authRepository, + authAPIService: authAPIService ) } diff --git a/Codive/DIContainer/ClosetDIContainer.swift b/Codive/DIContainer/ClosetDIContainer.swift index db0d8af2..21ce64fa 100644 --- a/Codive/DIContainer/ClosetDIContainer.swift +++ b/Codive/DIContainer/ClosetDIContainer.swift @@ -80,6 +80,26 @@ final class ClosetDIContainer { return CheckStatisticsConditionUseCase(repository: statisticsRepository) } + func makeFetchFavoriteItemsUseCase() -> FetchFavoriteItemsUseCase { + return FetchFavoriteItemsUseCase(repository: statisticsRepository) + } + + func makeFetchFavoriteCategoryItemsUseCase() -> FetchFavoriteCategoryItemsUseCase { + return FetchFavoriteCategoryItemsUseCase(repository: statisticsRepository) + } + + func makeFetchClosetUtilizationUseCase() -> FetchClosetUtilizationUseCase { + return FetchClosetUtilizationUseCase(repository: statisticsRepository) + } + + func makeFetchClothListByCategoryUseCase() -> FetchClothListByCategoryUseCase { + return FetchClothListByCategoryUseCase(repository: clothRepository) + } + + func makeFetchClothDetailUseCase() -> FetchClothDetailUseCase { + return FetchClothDetailUseCase(repository: clothRepository) + } + // MARK: - ViewModels func makeMyClosetViewModel() -> MyClosetViewModel { return MyClosetViewModel( @@ -108,7 +128,7 @@ final class ClosetDIContainer { cloth: cloth, navigationRouter: navigationRouter, deleteClothItemsUseCase: makeDeleteClothItemsUseCase(), - clothRepository: clothRepository + fetchClothDetailUseCase: makeFetchClothDetailUseCase() ) } @@ -123,7 +143,34 @@ final class ClosetDIContainer { func makeWardrobeReportDetailViewModel() -> WardrobeReportDetailViewModel { return WardrobeReportDetailViewModel( navigationRouter: navigationRouter, - checkStatisticsConditionUseCase: makeCheckStatisticsConditionUseCase() + checkStatisticsConditionUseCase: makeCheckStatisticsConditionUseCase(), + fetchFavoriteItemsUseCase: makeFetchFavoriteItemsUseCase(), + fetchFavoriteCategoryItemsUseCase: makeFetchFavoriteCategoryItemsUseCase(), + fetchClosetUtilizationUseCase: makeFetchClosetUtilizationUseCase() + ) + } + + func makeFavoriteByCategoryViewModel(parentCategoryId: Int64) -> FavoriteByCategoryViewModel { + return FavoriteByCategoryViewModel( + navigationRouter: navigationRouter, + fetchFavoriteCategoryItemsUseCase: makeFetchFavoriteCategoryItemsUseCase(), + fetchClothListByCategoryUseCase: makeFetchClothListByCategoryUseCase(), + parentCategoryId: parentCategoryId + ) + } + + func makeItemDataViewModel() -> ItemDataViewModel { + return ItemDataViewModel( + navigationRouter: navigationRouter, + fetchFavoriteItemsUseCase: makeFetchFavoriteItemsUseCase(), + fetchClothListByCategoryUseCase: makeFetchClothListByCategoryUseCase() + ) + } + + func makeWearingDataViewModel() -> WearingDataViewModel { + return WearingDataViewModel( + navigationRouter: navigationRouter, + fetchClosetUtilizationUseCase: makeFetchClosetUtilizationUseCase() ) } @@ -143,4 +190,16 @@ final class ClosetDIContainer { func makeWardrobeReportDetailView() -> some View { return WardrobeReportDetailView(viewModel: makeWardrobeReportDetailViewModel()) } + + func makeFavoriteByCategoryView(parentCategoryId: Int64) -> some View { + return FavoriteByCategoryView(viewModel: makeFavoriteByCategoryViewModel(parentCategoryId: parentCategoryId)) + } + + func makeItemDataView() -> some View { + return ItemDataView(viewModel: makeItemDataViewModel()) + } + + func makeWearingDataView() -> some View { + return WearingDataView(viewModel: makeWearingDataViewModel()) + } } diff --git a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift index a1dd28f2..afe252c4 100644 --- a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift +++ b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift @@ -88,13 +88,15 @@ struct TermsAgreementView: View { // 개별 항목들 ForEach(termsList, id: \.termId) { term in + let wikiURL = termsWikiURL(for: term.title) AgreementRow( title: term.title, isAgreed: binding(for: term.termId), - isRequired: !term.isOptional + isRequired: !term.isOptional, + showChevron: wikiURL != nil ) { - if let url = termsWikiURL(for: term.title) { - selectedTermsURL = TermsURL(url: url) + if let wikiURL { + selectedTermsURL = TermsURL(url: wikiURL) } } } @@ -124,7 +126,7 @@ struct TermsAgreementView: View { .padding(.horizontal, 20) .padding(.bottom, 10) } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .task { await loadTerms() } diff --git a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift index a9075e57..5411a047 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift @@ -14,7 +14,9 @@ final class OnboardingViewModel: ObservableObject { private let appRouter: AppRouter private let navigationRouter: NavigationRouter private let authRepository: AuthRepository - + private let authAPIService: AuthAPIServiceProtocol + private let profileAPIService: ProfileAPIServiceProtocol + // MARK: - Published Properties @Published var isLoading = false @Published var errorMessage: String? @@ -24,11 +26,15 @@ final class OnboardingViewModel: ObservableObject { init( appRouter: AppRouter, navigationRouter: NavigationRouter, - authRepository: AuthRepository + authRepository: AuthRepository, + authAPIService: AuthAPIServiceProtocol, + profileAPIService: ProfileAPIServiceProtocol = ProfileAPIService() ) { self.appRouter = appRouter self.navigationRouter = navigationRouter self.authRepository = authRepository + self.authAPIService = authAPIService + self.profileAPIService = profileAPIService } // MARK: - Actions @@ -91,12 +97,37 @@ final class OnboardingViewModel: ObservableObject { case .notAgreed: appRouter.navigateToTerms() case .registered: + await cacheMyProfile() + await sendFCMTokenToServer() appRouter.navigateToMain() } } catch { errorMessage = "회원 상태 확인에 실패했습니다." } } + + private func cacheMyProfile() async { + do { + let profile = try await profileAPIService.fetchMyProfile() + UserProfileStorage.save(profile) + } catch { + #if DEBUG + print("[Auth] 프로필 캐싱 실패: \(error)") + #endif + } + } + + private func sendFCMTokenToServer() async { + guard let fcmToken = UserDefaults.standard.string(forKey: "fcmToken") else { return } + + do { + try await authAPIService.renewDeviceToken(deviceToken: fcmToken) + } catch { + #if DEBUG + print("[Push] FCM 토큰 서버 전송 실패: \(error.localizedDescription)") + #endif + } + } // MARK: - Navigation Actions func navigateToLogin() { diff --git a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift index 44c70187..9e8e8d68 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift @@ -153,6 +153,7 @@ final class SplashViewModel: ObservableObject { appRouter.navigateToTerms() case .registered: await cacheMyProfile() + await sendFCMTokenToServer() appRouter.navigateToMain() } } catch { @@ -171,6 +172,18 @@ final class SplashViewModel: ObservableObject { } } + private func sendFCMTokenToServer() async { + guard let fcmToken = UserDefaults.standard.string(forKey: "fcmToken") else { return } + + do { + try await authAPIService.renewDeviceToken(deviceToken: fcmToken) + } catch { + #if DEBUG + print("[Push] FCM 토큰 서버 전송 실패: \(error.localizedDescription)") + #endif + } + } + private func clearTokensAndGoToAuth() { try? keychainManager.deleteAccessToken() try? keychainManager.deleteRefreshToken() diff --git a/Codive/Features/Closet/Data/DataSources/StatisticsDataSource.swift b/Codive/Features/Closet/Data/DataSources/StatisticsDataSource.swift index f67b7e98..7ed96610 100644 --- a/Codive/Features/Closet/Data/DataSources/StatisticsDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/StatisticsDataSource.swift @@ -11,6 +11,9 @@ import Foundation protocol StatisticsDataSource { func checkStatisticsCondition() async throws -> Bool + func getFavoriteItems() async throws -> [FavoriteItemPayload] + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload } // MARK: - DefaultStatisticsDataSource @@ -29,4 +32,16 @@ final class DefaultStatisticsDataSource: StatisticsDataSource { func checkStatisticsCondition() async throws -> Bool { return try await apiService.checkStatisticsCondition() } + + func getFavoriteItems() async throws -> [FavoriteItemPayload] { + return try await apiService.getFavoriteItems() + } + + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { + return try await apiService.getFavoriteCategoryItems(categoryId: categoryId) + } + + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + return try await apiService.getClosetUtilization(season: season) + } } diff --git a/Codive/Features/Closet/Data/Repositories/StatisticsRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/StatisticsRepositoryImpl.swift index a0fd3176..0cf9fbef 100644 --- a/Codive/Features/Closet/Data/Repositories/StatisticsRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/StatisticsRepositoryImpl.swift @@ -23,4 +23,16 @@ final class StatisticsRepositoryImpl: StatisticsRepository { func checkStatisticsCondition() async throws -> Bool { return try await dataSource.checkStatisticsCondition() } + + func getFavoriteItems() async throws -> [FavoriteItemPayload] { + return try await dataSource.getFavoriteItems() + } + + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { + return try await dataSource.getFavoriteCategoryItems(categoryId: categoryId) + } + + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + return try await dataSource.getClosetUtilization(season: season) + } } diff --git a/Codive/Features/Closet/Data/StatisticsAPIService.swift b/Codive/Features/Closet/Data/StatisticsAPIService.swift index 131882b7..03684039 100644 --- a/Codive/Features/Closet/Data/StatisticsAPIService.swift +++ b/Codive/Features/Closet/Data/StatisticsAPIService.swift @@ -13,6 +13,37 @@ import OpenAPIRuntime protocol StatisticsAPIServiceProtocol { func checkStatisticsCondition() async throws -> Bool + func getFavoriteItems() async throws -> [FavoriteItemPayload] + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload +} + +// MARK: - API Response DTOs + +struct FavoriteItemPayload { + let categoryId: Int64? + let categoryName: String + let clothCount: Int +} + +struct FavoriteCategoryItemPayload { + let categoryId: Int64? + let categoryName: String + let occupancyRate: Double + let clothCount: Int +} + +struct ClosetUtilizationPayload { + let utilizedCount: Int + let unutilizedCount: Int + let utilizedClothes: [ClosetUtilizationClothPayload] + let unutilizedClothes: [ClosetUtilizationClothPayload] +} + +struct ClosetUtilizationClothPayload { + let imageUrl: String + let name: String + let brand: String } // MARK: - StatisticsAPIService Implementation @@ -29,19 +60,140 @@ final class StatisticsAPIService: StatisticsAPIServiceProtocol { self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() } + // MARK: - Check Condition + func checkStatisticsCondition() async throws -> Bool { let input = Operations.Statistics_checkStatisticsCondition.Input() + #if DEBUG + print("[StatisticsAPI] 통계 조건 확인 요청 시작") + #endif let response = try await client.Statistics_checkStatisticsCondition(input) switch response { case .ok(let okResponse): let decoded = try okResponse.body.json + #if DEBUG + print("[StatisticsAPI] 응답 성공 - canAggregate: \(decoded.result?.canAggregate ?? false)") + #endif return decoded.result?.canAggregate ?? false case .undocumented(statusCode: let code, _): + #if DEBUG + print("[StatisticsAPI] 응답 실패 - statusCode: \(code)") + #endif throw StatisticsAPIError.serverError(statusCode: code, message: "통계 조건 확인 실패") } } + + // MARK: - Favorite Items (옷장 아이템 통계) + + func getFavoriteItems() async throws -> [FavoriteItemPayload] { + let input = Operations.Statistics_getFavoriteItems.Input() + #if DEBUG + print("[StatisticsAPI] 옷장 아이템 통계 요청 시작") + #endif + let response = try await client.Statistics_getFavoriteItems(input) + + switch response { + case .ok(let okResponse): + let decoded = try okResponse.body.json + #if DEBUG + print("[StatisticsAPI] 아이템 통계 응답 성공") + #endif + return (decoded.result?.payloads ?? []).map { payload in + FavoriteItemPayload( + categoryId: payload.categoryId, + categoryName: payload.categoryName ?? "", + clothCount: Int(payload.clothCount ?? 0) + ) + } + + case .undocumented(statusCode: let code, _): + throw StatisticsAPIError.serverError(statusCode: code, message: "아이템 통계 조회 실패") + } + } + + // MARK: - Favorite Category Items (카테고리별 최애 아이템) + + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { + let input = Operations.Statistics_getFavoriteCategoryItems.Input( + query: .init(categoryId: categoryId) + ) + #if DEBUG + print("[StatisticsAPI] 카테고리별 아이템 통계 요청 - categoryId: \(categoryId)") + #endif + let response = try await client.Statistics_getFavoriteCategoryItems(input) + + switch response { + case .ok(let okResponse): + let decoded = try okResponse.body.json + #if DEBUG + print("[StatisticsAPI] 카테고리별 아이템 응답 성공") + #endif + return (decoded.result?.payloads ?? []).map { payload in + FavoriteCategoryItemPayload( + categoryId: payload.categoryId, + categoryName: payload.categoryName ?? "", + occupancyRate: payload.occupancyRate ?? 0, + clothCount: Int(payload.clothCount ?? 0) + ) + } + + case .undocumented(statusCode: let code, _): + throw StatisticsAPIError.serverError(statusCode: code, message: "카테고리별 아이템 조회 실패") + } + } + + // MARK: - Closet Utilization (옷장 활용도) + + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + let seasonEnum: Operations.Statistics_getClosetUtilization.Input.Query.seasonPayload + switch season.uppercased() { + case "SPRING": seasonEnum = .SPRING + case "SUMMER": seasonEnum = .SUMMER + case "FALL": seasonEnum = .FALL + case "WINTER": seasonEnum = .WINTER + default: seasonEnum = .SPRING + } + + let input = Operations.Statistics_getClosetUtilization.Input( + query: .init(season: seasonEnum) + ) + #if DEBUG + print("[StatisticsAPI] 옷장 활용도 요청 - season: \(season)") + #endif + let response = try await client.Statistics_getClosetUtilization(input) + + switch response { + case .ok(let okResponse): + let decoded = try okResponse.body.json + let result = decoded.result + #if DEBUG + print("[StatisticsAPI] 활용도 응답 성공 - utilized: \(result?.utilizedCount ?? 0), unutilized: \(result?.unutilizedCount ?? 0)") + #endif + return ClosetUtilizationPayload( + utilizedCount: Int(result?.utilizedCount ?? 0), + unutilizedCount: Int(result?.unutilizedCount ?? 0), + utilizedClothes: (result?.utilizedClothes ?? []).map { + ClosetUtilizationClothPayload( + imageUrl: $0.imageUrl ?? "", + name: $0.name ?? "", + brand: $0.brand ?? "" + ) + }, + unutilizedClothes: (result?.unutilizedClothes ?? []).map { + ClosetUtilizationClothPayload( + imageUrl: $0.imageUrl ?? "", + name: $0.name ?? "", + brand: $0.brand ?? "" + ) + } + ) + + case .undocumented(statusCode: let code, _): + throw StatisticsAPIError.serverError(statusCode: code, message: "옷장 활용도 조회 실패") + } + } } // MARK: - StatisticsAPIError diff --git a/Codive/Features/Closet/Domain/Entities/WardrobeStatistics.swift b/Codive/Features/Closet/Domain/Entities/WardrobeStatistics.swift new file mode 100644 index 00000000..c33d8922 --- /dev/null +++ b/Codive/Features/Closet/Domain/Entities/WardrobeStatistics.swift @@ -0,0 +1,53 @@ +// +// WardrobeStatistics.swift +// Codive +// +// Created by Codive on 12/23/25. +// + +import Foundation + +struct ItemUsageStat: Identifiable, Hashable { + let id = UUID() + let itemName: String + let usageCount: Int +} + +struct WardrobeUsageStat: Hashable { + let totalCount: Int + let wornCount: Int + + var usagePercent: Int { + guard totalCount > 0 else { return 0 } + return Int(round((Double(wornCount) / Double(totalCount)) * 100)) + } +} + +struct ClothItem: Identifiable, Hashable { + let id: UUID = .init() + let imageUrl: String + let brand: String + let name: String + + init(imageUrl: String, brand: String, name: String) { + self.imageUrl = imageUrl + self.brand = brand + self.name = name + } + + /// `Cloth` 엔티티로부터 매핑. brand/name이 비어 있으면 카테고리로 fallback. + init(from cloth: Cloth) { + self.imageUrl = cloth.imageUrl + self.brand = cloth.brand?.trimmingCharacters(in: .whitespaces) ?? "" + + if let name = cloth.name?.trimmingCharacters(in: .whitespaces), !name.isEmpty { + self.name = name + } else if let sub = cloth.subCategory, !sub.isEmpty { + self.name = sub + } else if let main = cloth.mainCategory, !main.isEmpty { + self.name = main + } else { + self.name = "" + } + } +} diff --git a/Codive/Features/Closet/Domain/Protocols/StatisticsRepository.swift b/Codive/Features/Closet/Domain/Protocols/StatisticsRepository.swift index 83c545dc..1d790cbd 100644 --- a/Codive/Features/Closet/Domain/Protocols/StatisticsRepository.swift +++ b/Codive/Features/Closet/Domain/Protocols/StatisticsRepository.swift @@ -11,4 +11,7 @@ import Foundation protocol StatisticsRepository { func checkStatisticsCondition() async throws -> Bool + func getFavoriteItems() async throws -> [FavoriteItemPayload] + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload } diff --git a/Codive/Features/Closet/Domain/UseCases/FetchClosetUtilizationUseCase.swift b/Codive/Features/Closet/Domain/UseCases/FetchClosetUtilizationUseCase.swift new file mode 100644 index 00000000..770b2931 --- /dev/null +++ b/Codive/Features/Closet/Domain/UseCases/FetchClosetUtilizationUseCase.swift @@ -0,0 +1,21 @@ +// +// FetchClosetUtilizationUseCase.swift +// Codive +// +// Created by 황상환 on 5/3/26. +// + +import Foundation + +final class FetchClosetUtilizationUseCase { + + private let repository: StatisticsRepository + + init(repository: StatisticsRepository) { + self.repository = repository + } + + func execute(season: String) async throws -> ClosetUtilizationPayload { + return try await repository.getClosetUtilization(season: season) + } +} diff --git a/Codive/Features/Closet/Domain/UseCases/FetchClothDetailUseCase.swift b/Codive/Features/Closet/Domain/UseCases/FetchClothDetailUseCase.swift new file mode 100644 index 00000000..1875bc46 --- /dev/null +++ b/Codive/Features/Closet/Domain/UseCases/FetchClothDetailUseCase.swift @@ -0,0 +1,21 @@ +// +// FetchClothDetailUseCase.swift +// Codive +// +// 옷 상세 조회. +// + +import Foundation + +final class FetchClothDetailUseCase { + + private let repository: ClothRepository + + init(repository: ClothRepository) { + self.repository = repository + } + + func execute(clothId: Int) async throws -> ClothDetailResult { + try await repository.fetchClothDetail(clothId: clothId) + } +} diff --git a/Codive/Features/Closet/Domain/UseCases/FetchClothListByCategoryUseCase.swift b/Codive/Features/Closet/Domain/UseCases/FetchClothListByCategoryUseCase.swift new file mode 100644 index 00000000..8bdcd6cb --- /dev/null +++ b/Codive/Features/Closet/Domain/UseCases/FetchClothListByCategoryUseCase.swift @@ -0,0 +1,30 @@ +// +// FetchClothListByCategoryUseCase.swift +// Codive +// +// 카테고리 기준 옷 목록 조회 (옷장 리포트의 카테고리별/아이템별 바텀시트용). +// + +import Foundation + +final class FetchClothListByCategoryUseCase { + + private let repository: ClothRepository + + init(repository: ClothRepository) { + self.repository = repository + } + + func execute( + categoryId: Int, + size: Int = 50 + ) async throws -> [Cloth] { + let result = try await repository.fetchClothList( + lastClothId: nil, + size: size, + categoryId: categoryId, + seasons: [] + ) + return result.clothes + } +} diff --git a/Codive/Features/Closet/Domain/UseCases/FetchFavoriteCategoryItemsUseCase.swift b/Codive/Features/Closet/Domain/UseCases/FetchFavoriteCategoryItemsUseCase.swift new file mode 100644 index 00000000..b1e3ea60 --- /dev/null +++ b/Codive/Features/Closet/Domain/UseCases/FetchFavoriteCategoryItemsUseCase.swift @@ -0,0 +1,21 @@ +// +// FetchFavoriteCategoryItemsUseCase.swift +// Codive +// +// Created by 황상환 on 5/3/26. +// + +import Foundation + +final class FetchFavoriteCategoryItemsUseCase { + + private let repository: StatisticsRepository + + init(repository: StatisticsRepository) { + self.repository = repository + } + + func execute(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { + return try await repository.getFavoriteCategoryItems(categoryId: categoryId) + } +} diff --git a/Codive/Features/Closet/Domain/UseCases/FetchFavoriteItemsUseCase.swift b/Codive/Features/Closet/Domain/UseCases/FetchFavoriteItemsUseCase.swift new file mode 100644 index 00000000..ed38cbd2 --- /dev/null +++ b/Codive/Features/Closet/Domain/UseCases/FetchFavoriteItemsUseCase.swift @@ -0,0 +1,21 @@ +// +// FetchFavoriteItemsUseCase.swift +// Codive +// +// Created by 황상환 on 5/3/26. +// + +import Foundation + +final class FetchFavoriteItemsUseCase { + + private let repository: StatisticsRepository + + init(repository: StatisticsRepository) { + self.repository = repository + } + + func execute() async throws -> [FavoriteItemPayload] { + return try await repository.getFavoriteItems() + } +} diff --git a/Codive/Features/Closet/Presentation/Components/CategoryFavoriteItem.swift b/Codive/Features/Closet/Presentation/Components/CategoryFavoriteItem.swift new file mode 100644 index 00000000..b0af58ff --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/CategoryFavoriteItem.swift @@ -0,0 +1,16 @@ +// +// CategoryFavoriteItem.swift +// Codive +// +// Presentation 전용 표현 모델. DonutSegment(색상 포함)를 들고 있어 +// SwiftUI에 의존하므로 Presentation 레이어에서 정의한다. +// + +import SwiftUI + +struct CategoryFavoriteItem: Identifiable, Hashable { + let id = UUID() + let parentCategoryId: Int64 + let categoryName: String + let items: [DonutSegment] +} diff --git a/Codive/Features/Closet/Presentation/Components/DataBottomSheet.swift b/Codive/Features/Closet/Presentation/Components/DataBottomSheet.swift new file mode 100644 index 00000000..574b9b94 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/DataBottomSheet.swift @@ -0,0 +1,114 @@ +// +// DataBottomSheet.swift +// Codive +// +// Created by 한태빈 on 2025/12/23. +// + +import SwiftUI +import Kingfisher + +struct DataBottomSheet: View { + + // MARK: - Inputs + let title: String + let totalCount: Int + let items: [ClothItem] + + // MARK: - Layout + private let cornerRadius: CGFloat = 24 + private let handleSize = CGSize(width: 52, height: 4) + + private var columns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 10), count: 3) + } + + var body: some View { + VStack(spacing: 0) { + handle + header + grid + } + .background(Color.white) + .clipShape(RoundedCorner(radius: cornerRadius, corners: [.topLeft, .topRight])) + } + + private var handle: some View { + Capsule() + .fill(Color.Codive.grayscale5) + .frame(width: handleSize.width, height: handleSize.height) + .padding(.top, 10) + .padding(.bottom, 10) + } + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + + Text("총 \(max(0, totalCount))벌") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale4) + + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 14) + } + + private var grid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { item in + VStack(alignment: .leading, spacing: 6) { + KFImage(URL(string: item.imageUrl)) + .resizable() + .scaledToFill() + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + .background(Color.Codive.grayscale6) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + if !item.brand.isEmpty { + Text(item.brand) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale4) + .lineLimit(1) + } + + Text(item.name) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale1) + .lineLimit(2) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 24) + } + } +} + +// MARK: - View Modifier + +extension View { + func dataBottomSheet( + isPresented: Binding, + title: String, + totalCount: Int, + items: [ClothItem] + ) -> some View { + self.sheet(isPresented: isPresented) { + DataBottomSheet( + title: title, + totalCount: totalCount, + items: items + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } + } +} diff --git a/Codive/Features/Closet/Presentation/Components/FavoriteDonutCard.swift b/Codive/Features/Closet/Presentation/Components/FavoriteDonutCard.swift new file mode 100644 index 00000000..39d99c25 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/FavoriteDonutCard.swift @@ -0,0 +1,144 @@ +// +// FavoriteDonutCard.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct FavoriteDonutCard: View { + + let categoryTitle: String + let segments: [DonutSegment] + + @State private var selectedID: DonutSegment.ID? + + private var total: Double { + segments.map(\.value).reduce(0, +) + } + + private var selectedPercentText: String { + guard let selectedID, + let seg = segments.first(where: { $0.id == selectedID }), + total > 0 + else { return "" } + let percent = Int(round((seg.value / total) * 100)) + return "\(percent)%" + } + + var body: some View { + ZStack(alignment: .topTrailing) { + HStack(spacing: 35) { + ZStack { + DonutChartView( + segments: segments, + selectedID: $selectedID, + thickness: 28, + gapDegrees: 0, + cornerRadius: 6 + ) { + Text(categoryTitle) + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + .frame(width: 116, height: 116) + // 도넛 자체 탭으로 segment 강조 변경되지 않게 비활성화 (카드 전체 탭으로 상세 이동) + .allowsHitTesting(false) + + if !selectedPercentText.isEmpty { + PercentBubbleView(text: selectedPercentText) + .offset(x: 34, y: -40) + .allowsHitTesting(false) + } + } + + VStack(alignment: .leading, spacing: 16) { + ForEach(segments, id: \.id) { row in + HStack(spacing: 8) { + Circle() + .fill(row.color) + .frame(width: 14, height: 14) + + if let name = row.payload { + Text(name) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + } + } + + Spacer(minLength: 0) + } + .padding(.vertical, 20) + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale4) + .padding(.trailing, 14) + .padding(.top, 16) + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.Codive.grayscale6, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.04), radius: 8, x: 0, y: 4) + .onAppear { + selectedID = segments.first?.id + } + } +} + +// MARK: - PercentBubbleView + +private struct PercentBubbleView: View { + let text: String + + var body: some View { + Text(text) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + SpeechBubbleShape() + .fill(Color.white) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3) + ) + } +} + +// MARK: - Preview + +#Preview("상의 - 4개 항목") { + FavoriteDonutCard( + categoryTitle: "상의", + segments: [ + DonutSegment(value: 45, color: .Codive.point1, payload: "맨투맨"), + DonutSegment(value: 30, color: .Codive.point2, payload: "후드티"), + DonutSegment(value: 15, color: .Codive.point3, payload: "셔츠"), + DonutSegment(value: 10, color: .Codive.grayscale5, payload: "기타") + ] + ) + .frame(width: 300, height: 182) + .padding() + .background(Color.Codive.grayscale7) +} + +#Preview("하의 - 2개 항목") { + FavoriteDonutCard( + categoryTitle: "하의", + segments: [ + DonutSegment(value: 70, color: .Codive.point1, payload: "청바지"), + DonutSegment(value: 30, color: .Codive.point2, payload: "면바지") + ] + ) + .frame(width: 300, height: 182) + .padding() + .background(Color.Codive.grayscale7) +} diff --git a/Codive/Features/Closet/Presentation/Components/ItemBarChart.swift b/Codive/Features/Closet/Presentation/Components/ItemBarChart.swift new file mode 100644 index 00000000..90245279 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/ItemBarChart.swift @@ -0,0 +1,139 @@ +// +// ItemBarChart.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct ItemBarChart: View { + let stats: [ItemUsageStat] + var maxBarHeight: CGFloat = 140 + var unitSuffix: String = "벌" + + private let barCornerRadius: CGFloat = 8 + private let minBarHeight: CGFloat = 12 + + private var maxCount: Int { + max(stats.map(\.usageCount).max() ?? 1, 1) + } + + // 수량 기준으로 내림차순 정렬 (가장 많은 것이 가장 진한 색) + private var sortedStats: [ItemUsageStat] { + stats.sorted { $0.usageCount > $1.usageCount } + } + + private let barColors: [Color] = [ + Color.Codive.point1, + Color.Codive.point2, + Color.Codive.point3, + Color.Codive.main5, + Color.Codive.grayscale6 + ] + + var body: some View { + HStack(alignment: .bottom, spacing: 15) { + ForEach(Array(sortedStats.enumerated()), id: \.offset) { idx, item in + let isHighlighted = idx == 0 + let height = max( + minBarHeight, + CGFloat(item.usageCount) / CGFloat(maxCount) * maxBarHeight + ) + + VStack(spacing: 8) { + ZStack(alignment: .top) { + TopRoundedRectangle(cornerRadius: barCornerRadius) + .fill(barColors[min(idx, barColors.count - 1)]) + .frame(maxWidth: .infinity) + .frame(height: height) + + if isHighlighted { + highlightLabel(count: item.usageCount) + .offset(y: 6) + } + } + .frame(height: maxBarHeight, alignment: .bottom) + + Text(item.itemName) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale3) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity) + } + } + // 카드 내부 padding 16 + 여기 24 = 카드 가장자리 기준 40 좌우 여백 + .padding(.horizontal, 24) + } + + private func highlightLabel(count: Int) -> some View { + Text("\(count)\(unitSuffix)") + .font(.codive_body3_medium) + .foregroundStyle(Color.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.Codive.point1) + ) + } +} + +// MARK: - TopRoundedRectangle +// 위쪽 두 모서리만 둥근 사각형. 막대 차트 막대 모양 등에 사용. + +private struct TopRoundedRectangle: Shape { + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + let r = max(0, min(cornerRadius, min(rect.width, rect.height) / 2)) + + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + r, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + r), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +// MARK: - Preview + +#Preview("디자인 매칭") { + ItemBarChart( + stats: [ + ItemUsageStat(itemName: "맨투맨", usageCount: 8), + ItemUsageStat(itemName: "원피스", usageCount: 6), + ItemUsageStat(itemName: "니트", usageCount: 4), + ItemUsageStat(itemName: "후드티", usageCount: 3), + ItemUsageStat(itemName: "레깅스", usageCount: 2) + ] + ) + .padding() + .background(Color.white) +} + +#Preview("값 차이 큰 케이스") { + ItemBarChart( + stats: [ + ItemUsageStat(itemName: "티셔츠", usageCount: 20), + ItemUsageStat(itemName: "셔츠", usageCount: 5), + ItemUsageStat(itemName: "니트", usageCount: 3), + ItemUsageStat(itemName: "후드티", usageCount: 2), + ItemUsageStat(itemName: "맨투맨", usageCount: 1) + ] + ) + .padding() + .background(Color.white) +} diff --git a/Codive/Features/Closet/Presentation/Components/ReportCardContainer.swift b/Codive/Features/Closet/Presentation/Components/ReportCardContainer.swift new file mode 100644 index 00000000..d9c057e8 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/ReportCardContainer.swift @@ -0,0 +1,39 @@ +// +// ReportCardContainer.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct ReportCardContainer: View { + @ViewBuilder let content: Content + + var body: some View { + content + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.Codive.grayscale6, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.04), radius: 8, x: 0, y: 4) + .padding(.horizontal, 20) + } +} + +// MARK: - Preview + +#Preview { + ReportCardContainer { + Text("리포트 카드 콘텐츠") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + .frame(height: 120) + } + .padding(.vertical, 40) + .background(Color.Codive.grayscale7) +} diff --git a/Codive/Features/Closet/Presentation/Components/ReportSectionHeader.swift b/Codive/Features/Closet/Presentation/Components/ReportSectionHeader.swift new file mode 100644 index 00000000..02e5e050 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/ReportSectionHeader.swift @@ -0,0 +1,100 @@ +// +// ReportSectionHeader.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +// MARK: - ReportSectionHeader + +struct ReportSectionHeader: View { + + let title: String + let tooltip: String + @Binding var showingTooltip: String? + + private var isShown: Bool { showingTooltip == tooltip } + + var body: some View { + HStack(spacing: 6) { + Text(title) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showingTooltip = isShown ? nil : tooltip + } + } label: { + Image(systemName: "info.circle") + .font(.system(size: 18)) + .foregroundStyle(Color.Codive.grayscale4) + } + // 페이지 루트가 받아서 모든 카드 위에 띄울 수 있도록 ⓘ 버튼 위치를 anchor로 전달 + .anchorPreference(key: TooltipAnchorKey.self, value: .bounds) { anchor in + isShown ? TooltipAnchor(text: tooltip, anchor: anchor) : nil + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + } +} + +// MARK: - PreferenceKey + +struct TooltipAnchor: Equatable { + let text: String + let anchor: Anchor + + static func == (lhs: TooltipAnchor, rhs: TooltipAnchor) -> Bool { + lhs.text == rhs.text + } +} + +struct TooltipAnchorKey: PreferenceKey { + static var defaultValue: TooltipAnchor? + static func reduce(value: inout TooltipAnchor?, nextValue: () -> TooltipAnchor?) { + value = value ?? nextValue() + } +} + +// MARK: - TooltipBubbleView + +struct TooltipBubbleView: View { + let text: String + + var body: some View { + Text(text) + .font(.codive_body3_regular) + .foregroundStyle(Color.Codive.grayscale3) + .multilineTextAlignment(.leading) + .lineSpacing(4) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.Codive.grayscale6) + ) + } +} + +// MARK: - Preview + +#Preview("툴팁 닫힘") { + ReportSectionHeader( + title: "옷장 아이템 통계", + tooltip: "전체 아이템 중 많이 보유한 아이템\nTOP 5를 확인할 수 있는 그래프입니다", + showingTooltip: .constant(nil) + ) + .padding(.vertical, 16) + .background(Color.Codive.grayscale7) +} + +#Preview("툴팁 단독") { + TooltipBubbleView(text: "전체 아이템 중 많이 보유한 아이템\nTOP 5를 확인할 수 있는 그래프입니다") + .padding() + .background(Color.Codive.grayscale7) +} diff --git a/Codive/Features/Closet/Presentation/Components/StatsComponent.swift b/Codive/Features/Closet/Presentation/Components/StatsComponent.swift new file mode 100644 index 00000000..ef2ac1d0 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/StatsComponent.swift @@ -0,0 +1,279 @@ +// +// StatsComponent.swift +// Codive +// +// Created by 황상환 on 12/14/25. +// + +import Foundation +import SwiftUI + +// MARK: - DonutSegment Model + +struct DonutSegment: Identifiable, Hashable { + let id: UUID = .init() + let value: Double + let color: Color + var payload: String? +} + +// MARK: - DonutChartView + +struct DonutChartView: View { + + let segments: [DonutSegment] + @Binding var selectedID: DonutSegment.ID? + + var thickness: CGFloat = 45 + /// 비선택 세그먼트끼리의 gap (0이면 매끈한 링) + var gapDegrees: Double = 0 + /// 선택된 세그먼트의 모서리 라운드 + var cornerRadius: CGFloat = 6 + var rotationDegrees: Double = -90 + /// 강조된 세그먼트가 바깥쪽으로 얼마나 튀어나올지 + var selectedOuterExtension: CGFloat = 6 + /// 강조된 세그먼트가 안쪽으로 얼마나 들어갈지 (양쪽 두께 확장으로 강조감 ↑) + var selectedInnerExtension: CGFloat = 3 + /// 강조된 세그먼트 양옆에 자체 inset(각도)을 줘서 작은 gap을 만든다 + var selectedAngularInset: Double = 2 + /// 비선택 세그먼트 양옆을 살짝 확장해 인접 segment와 겹치게 만듦 (anti-alias 흰선 제거) + var unselectedOverlapDegrees: Double = 0.4 + + @ViewBuilder var centerContent: () -> CenterContent + + var body: some View { + GeometryReader { geo in + let size = min(geo.size.width, geo.size.height) + // 강조 시 바깥으로 확장되므로 outer 반지름은 selectedOuterExtension만큼 여유 둠 + let outerRadius = size / 2 - selectedOuterExtension + let innerRadius = max(0, outerRadius - thickness) + + ZStack { + ForEach( + computedSegments( + totalSize: size, + innerRadius: innerRadius, + outerRadius: outerRadius + ) + ) { item in + let isSelected = selectedID == item.segment.id + let startInset = isSelected ? selectedAngularInset : -unselectedOverlapDegrees + let endInset = isSelected ? selectedAngularInset : -unselectedOverlapDegrees + DonutSegmentView( + color: item.segment.color, + startAngle: item.startAngle + startInset, + endAngle: item.endAngle - endInset, + innerRadius: item.innerRadius - (isSelected ? selectedInnerExtension : 0), + outerRadius: item.outerRadius + (isSelected ? selectedOuterExtension : 0), + cornerRadius: isSelected ? cornerRadius : 0, + isSelected: isSelected + ) + .zIndex(isSelected ? 1 : 0) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + selectedID = item.segment.id + } + } + } + + centerContent() + .rotationEffect(.degrees(-rotationDegrees)) + } + .frame(width: size, height: size) + .rotationEffect(.degrees(rotationDegrees)) + } + .aspectRatio(1, contentMode: .fit) + } + + // MARK: - Private + + private let maxGapPortion: Double = 0.6 + + private func computedSegments( + totalSize: CGFloat, + innerRadius: CGFloat, + outerRadius: CGFloat + ) -> [ComputedSegment] { + + // 0값 세그먼트 필터링 + let validSegments = segments.filter { $0.value > 0 } + let total = validSegments.map(\.value).reduce(0, +) + guard total > 0, !validSegments.isEmpty else { return [] } + + let segmentCount = Double(validSegments.count) + let safeGap = segmentCount > 1 + ? max(0, min(gapDegrees, (360.0 / segmentCount) * maxGapPortion)) + : 0 + let available = 360.0 - safeGap * segmentCount + + var current = 0.0 + var result: [ComputedSegment] = [] + + for seg in validSegments { + let portion = seg.value / total + let span = max(0, available * portion) + + result.append( + ComputedSegment( + id: seg.id, + segment: seg, + startAngle: current, + endAngle: current + span, + innerRadius: innerRadius, + outerRadius: outerRadius + ) + ) + current += span + safeGap + } + return result + } + + private struct ComputedSegment: Identifiable { + let id: DonutSegment.ID + let segment: DonutSegment + let startAngle: Double + let endAngle: Double + let innerRadius: CGFloat + let outerRadius: CGFloat + } +} + +// MARK: - DonutSegmentView + +private struct DonutSegmentView: View { + let color: Color + let startAngle: Double + let endAngle: Double + let innerRadius: CGFloat + let outerRadius: CGFloat + let cornerRadius: CGFloat + let isSelected: Bool + + var body: some View { + RoundedDonutSlice( + startAngle: startAngle, + endAngle: endAngle, + innerRadius: innerRadius, + outerRadius: outerRadius, + cornerRadius: cornerRadius + ) + .fill(color) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: isSelected) + } +} + +// MARK: - RoundedDonutSlice +// 4개 모서리가 둥근 도넛 한 조각. fill만으로 어긋남 없이 깨끗하게 그려진다. + +struct RoundedDonutSlice: Shape { + + var startAngle: Double // degrees + var endAngle: Double + var innerRadius: CGFloat + var outerRadius: CGFloat + var cornerRadius: CGFloat + + var animatableData: AnimatablePair< + AnimatablePair, + AnimatablePair + > { + get { + AnimatablePair( + AnimatablePair(startAngle, endAngle), + AnimatablePair(innerRadius, outerRadius) + ) + } + set { + startAngle = newValue.first.first + endAngle = newValue.first.second + innerRadius = newValue.second.first + outerRadius = newValue.second.second + } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + let center = CGPoint(x: rect.midX, y: rect.midY) + + let thickness = max(0, outerRadius - innerRadius) + // 코너 반지름이 두께 절반을 못 넘게 클램프 + let cornerR = max(0, min(cornerRadius, thickness / 2)) + + let startRad = startAngle * .pi / 180 + let endRad = endAngle * .pi / 180 + let sweep = endRad - startRad + + // 세그먼트가 너무 작아서 코너 라운드가 들어갈 공간이 없으면 일반 sector로 폴백 + let outerAngularInset = outerRadius > 0 ? cornerR / outerRadius : 0 + let innerAngularInset = innerRadius > 0 ? cornerR / innerRadius : 0 + let totalAngularInset = outerAngularInset * 2 + guard cornerR > 0, sweep > totalAngularInset, innerRadius > 0 else { + path.addArc( + center: center, radius: outerRadius, + startAngle: .radians(startRad), endAngle: .radians(endRad), + clockwise: false + ) + path.addArc( + center: center, radius: innerRadius, + startAngle: .radians(endRad), endAngle: .radians(startRad), + clockwise: true + ) + path.closeSubpath() + return path + } + + let outerStart = startRad + outerAngularInset + let outerEnd = endRad - outerAngularInset + let innerStart = startRad + innerAngularInset + let innerEnd = endRad - innerAngularInset + + // 각 코너의 점들 — quadCurve의 시작/끝/control 위치 + func point(angle: Double, radius: CGFloat) -> CGPoint { + let cosValue: Double = Foundation.cos(angle) + let sinValue: Double = Foundation.sin(angle) + return CGPoint( + x: center.x + CGFloat(cosValue) * radius, + y: center.y + CGFloat(sinValue) * radius + ) + } + + // 시작 outer corner + let p1 = point(angle: startRad, radius: outerRadius - cornerR) + let p2 = point(angle: outerStart, radius: outerRadius) + let cp12 = point(angle: startRad, radius: outerRadius) + + // 끝 outer corner + let p4 = point(angle: endRad, radius: outerRadius - cornerR) + let cp34 = point(angle: endRad, radius: outerRadius) + + // 끝 inner corner + let p5 = point(angle: endRad, radius: innerRadius + cornerR) + let p6 = point(angle: innerEnd, radius: innerRadius) + let cp56 = point(angle: endRad, radius: innerRadius) + + // 시작 inner corner + let p8 = point(angle: startRad, radius: innerRadius + cornerR) + let cp78 = point(angle: startRad, radius: innerRadius) + + path.move(to: p1) + path.addQuadCurve(to: p2, control: cp12) + path.addArc( + center: center, radius: outerRadius, + startAngle: .radians(outerStart), endAngle: .radians(outerEnd), + clockwise: false + ) + path.addQuadCurve(to: p4, control: cp34) + path.addLine(to: p5) + path.addQuadCurve(to: p6, control: cp56) + path.addArc( + center: center, radius: innerRadius, + startAngle: .radians(innerEnd), endAngle: .radians(innerStart), + clockwise: true + ) + path.addQuadCurve(to: p8, control: cp78) + path.addLine(to: p1) + path.closeSubpath() + + return path + } +} diff --git a/Codive/Features/Closet/Presentation/Components/UsageDonutChart.swift b/Codive/Features/Closet/Presentation/Components/UsageDonutChart.swift new file mode 100644 index 00000000..14a31188 --- /dev/null +++ b/Codive/Features/Closet/Presentation/Components/UsageDonutChart.swift @@ -0,0 +1,96 @@ +// +// UsageDonutChart.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct UsageDonutChart: View { + let stats: WardrobeUsageStat + @State private var segments: [DonutSegment] = [] + @State private var selectedID: DonutSegment.ID? + + var body: some View { + DonutChartView( + segments: segments, + selectedID: $selectedID, + thickness: 26, + gapDegrees: 0, + cornerRadius: 0, + selectedOuterExtension: 0, + selectedInnerExtension: 0, + selectedAngularInset: 0 + ) { + Text("\(stats.usagePercent)%") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(Color.Codive.point1) + } + .onAppear(perform: rebuild) + .onChange(of: stats) { _ in rebuild() } + } + + private func rebuild() { + var newSegments: [DonutSegment] = [] + if stats.wornCount > 0 { + newSegments.append( + DonutSegment( + value: Double(stats.wornCount), + color: Color.Codive.point1, + payload: "입음" + ) + ) + } + let notWorn = max(0, stats.totalCount - stats.wornCount) + if notWorn > 0 { + newSegments.append( + DonutSegment( + value: Double(notWorn), + color: Color.Codive.point4, + payload: "미착용" + ) + ) + } + segments = newSegments + selectedID = nil + } +} + +// MARK: - Preview + +#Preview("디자인 매칭 - 40%") { + UsageDonutChart( + stats: WardrobeUsageStat(totalCount: 20, wornCount: 8) + ) + .frame(width: 130, height: 130) + .padding() + .background(Color.white) +} + +#Preview("70% 활용") { + UsageDonutChart( + stats: WardrobeUsageStat(totalCount: 30, wornCount: 21) + ) + .frame(width: 130, height: 130) + .padding() + .background(Color.white) +} + +#Preview("100% 활용") { + UsageDonutChart( + stats: WardrobeUsageStat(totalCount: 15, wornCount: 15) + ) + .frame(width: 130, height: 130) + .padding() + .background(Color.white) +} + +#Preview("0% 활용") { + UsageDonutChart( + stats: WardrobeUsageStat(totalCount: 15, wornCount: 0) + ) + .frame(width: 130, height: 130) + .padding() + .background(Color.white) +} diff --git a/Codive/Features/Closet/Presentation/View/ClothAddView.swift b/Codive/Features/Closet/Presentation/View/ClothAddView.swift index a8a6b265..c6fe4240 100644 --- a/Codive/Features/Closet/Presentation/View/ClothAddView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothAddView.swift @@ -89,7 +89,7 @@ struct ClothAddView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .customToast( diff --git a/Codive/Features/Closet/Presentation/View/ClothEditView.swift b/Codive/Features/Closet/Presentation/View/ClothEditView.swift index 6fd0e9bd..7ea0532e 100644 --- a/Codive/Features/Closet/Presentation/View/ClothEditView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothEditView.swift @@ -64,7 +64,7 @@ struct ClothEditView: View { .task { await viewModel.fetchDetail() } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .sheet(isPresented: $viewModel.isCategorySheetPresented) { diff --git a/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift b/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift index 026fe78b..2b2a26cf 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift @@ -76,7 +76,7 @@ struct ClothDetailView: View { .task { await viewModel.fetchDetail() } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .alert("옷 삭제", isPresented: $viewModel.showDeleteAlert) { diff --git a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift index 9635a293..bfa16e06 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift @@ -50,6 +50,7 @@ struct MyClosetView: View { } .padding(.horizontal, 20) .padding(.vertical, 10) + .padding(.bottom, 8) mainCategoryTab @@ -97,7 +98,7 @@ struct MyClosetView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .ignoresSafeArea(.all, edges: .bottom) diff --git a/Codive/Features/Closet/Presentation/View/report/FavoriteByCategorySection.swift b/Codive/Features/Closet/Presentation/View/report/FavoriteByCategorySection.swift new file mode 100644 index 00000000..bd3ec955 --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/FavoriteByCategorySection.swift @@ -0,0 +1,93 @@ +// +// FavoriteByCategorySection.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct FavoriteByCategorySection: View { + + let categories: [CategoryFavoriteItem] + @Binding var showingTooltip: String? + let onCardTap: (Int64) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ReportSectionHeader( + title: "카테고리별 최애 아이템", + tooltip: "코디 결정하기와 기록에서 태그한 옷을 기반으로\n카테고리마다 얼마나 많이 입었는지 보여주는 통계입니다", + showingTooltip: $showingTooltip + ) + + if !categories.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(categories) { item in + FavoriteDonutCard( + categoryTitle: item.categoryName, + segments: item.items + ) + .frame(width: 332, height: 190) + .onTapGesture { onCardTap(item.parentCategoryId) } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 2) + } + } + } + } +} + +// MARK: - Preview + +#Preview("기본 - 카드 여러 장") { + ScrollView { + FavoriteByCategorySection( + categories: [ + CategoryFavoriteItem( + parentCategoryId: 1, + categoryName: "상의", + items: [ + DonutSegment(value: 45, color: .Codive.point1, payload: "맨투맨"), + DonutSegment(value: 30, color: .Codive.point2, payload: "후드티"), + DonutSegment(value: 15, color: .Codive.point3, payload: "셔츠"), + DonutSegment(value: 10, color: .Codive.grayscale5, payload: "기타") + ] + ), + CategoryFavoriteItem( + parentCategoryId: 2, + categoryName: "하의", + items: [ + DonutSegment(value: 60, color: .Codive.point1, payload: "청바지"), + DonutSegment(value: 25, color: .Codive.point2, payload: "면바지"), + DonutSegment(value: 15, color: .Codive.point3, payload: "반바지") + ] + ), + CategoryFavoriteItem( + parentCategoryId: 5, + categoryName: "아우터", + items: [ + DonutSegment(value: 50, color: .Codive.point1, payload: "코트"), + DonutSegment(value: 35, color: .Codive.point2, payload: "패딩"), + DonutSegment(value: 15, color: .Codive.point3, payload: "자켓") + ] + ) + ], + showingTooltip: .constant(nil) + ) { _ in } + } + .background(Color.Codive.grayscale7) +} + +#Preview("빈 상태") { + ScrollView { + FavoriteByCategorySection( + categories: [], + showingTooltip: .constant(nil) + ) { _ in } + } + .background(Color.Codive.grayscale7) +} diff --git a/Codive/Features/Closet/Presentation/View/report/FavoriteByCategoryView.swift b/Codive/Features/Closet/Presentation/View/report/FavoriteByCategoryView.swift new file mode 100644 index 00000000..2bbbb8e9 --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/FavoriteByCategoryView.swift @@ -0,0 +1,312 @@ +// +// FavoriteByCategoryView.swift +// Codive +// +// Created by 한태빈 on 12/19/25. +// + +import Foundation +import SwiftUI + +struct FavoriteByCategoryView: View { + + @StateObject private var viewModel: FavoriteByCategoryViewModel + @State private var isExpanded: Bool = false + + private let collapsedHeight: CGFloat = 420 + + init(viewModel: FavoriteByCategoryViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + // 메인 콘텐츠 (도넛은 상단에) + VStack(spacing: 0) { + CustomNavigationBar(title: "카테고리 통계") { + viewModel.navigateBack() + } + .background(Color.white) + + Text("그래프를 눌러 구체적인 히스토리를 살펴보세요") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 12) + + if viewModel.isLoading { + Spacer() + ProgressView() + .frame(maxWidth: .infinity) + Spacer() + } else if let firstCategory = viewModel.categories.first { + CategoryDonutSection( + item: firstCategory, + onSelectSegment: { segmentName, _ in + viewModel.selectSegment(name: segmentName) + } + ) + .padding(.top, 16) + Spacer(minLength: 0) + // 바텀시트 영역만큼 빈 공간 확보 + Color.clear.frame(height: collapsedHeight - 60) + } else { + Spacer() + } + } + + // 바텀시트 (페이지 하단에 고정) + DataBottomSheet( + title: viewModel.selectedBottomSheetTitle, + totalCount: viewModel.selectedBottomSheetItems.count, + items: viewModel.selectedBottomSheetItems + ) + .frame( + width: geometry.size.width, + height: isExpanded ? geometry.size.height * 0.75 : collapsedHeight + ) + .shadow(color: .black.opacity(0.08), radius: 10, x: 0, y: -4) + .gesture( + DragGesture() + .onEnded { value in + withAnimation(.spring()) { + if value.translation.height < -50 { + isExpanded = true + } else if value.translation.height > 50 { + isExpanded = false + } + } + } + ) + .ignoresSafeArea(.all, edges: .bottom) + } + .background(Color.white) + .toolbar(.hidden, for: .navigationBar) + .task { + await viewModel.loadData() + } + } + .ignoresSafeArea(.all, edges: .bottom) + } +} + +// MARK: - CategoryDonutSection + +private struct CategoryDonutSection: View { + let item: CategoryFavoriteItem + var onSelectSegment: (String, [ClothItem]) -> Void + + @State private var selectedID: DonutSegment.ID? + + private var total: Double { + item.items.map(\.value).reduce(0, +) + } + + private var selectedSegment: DonutSegment? { + guard let selectedID else { return nil } + return item.items.first(where: { $0.id == selectedID }) + } + + private var selectedPercent: Int { + guard let seg = selectedSegment, total > 0 else { return 0 } + return Int(round((seg.value / total) * 100)) + } + + /// 강조된 segment의 가운데 각도에 맞춰 말풍선이 도넛 외곽에 위치하도록 offset 계산 + private var bubbleOffset: CGSize { + guard let selectedID else { return .zero } + + let validSegments = item.items.filter { $0.value > 0 } + let total = validSegments.map(\.value).reduce(0, +) + guard total > 0 else { return .zero } + + let segmentCount = Double(validSegments.count) + let gapDegrees: Double = 0 + let safeGap = segmentCount > 1 + ? max(0, min(gapDegrees, (360.0 / segmentCount) * 0.6)) + : 0 + let available = 360.0 - safeGap * segmentCount + + var current = 0.0 + var centerAngleDeg: Double = 0 + for seg in validSegments { + let portion = seg.value / total + let span = available * portion + if seg.id == selectedID { + centerAngleDeg = current + span / 2 + break + } + current += span + safeGap + } + + // DonutChartView 기본 rotationDegrees: -90 (12시 방향 시작) + let actualRad = (centerAngleDeg - 90) * .pi / 180 + let radius: CGFloat = 116 + let x = CGFloat(Foundation.cos(actualRad)) * radius + let y = CGFloat(Foundation.sin(actualRad)) * radius + return CGSize(width: x, height: y) + } + + var body: some View { + ZStack { + DonutChartView( + segments: item.items, + selectedID: $selectedID, + thickness: 54, + gapDegrees: 0, + cornerRadius: 7, + selectedOuterExtension: 5, + selectedInnerExtension: 3, + selectedAngularInset: 1.5 + ) { + ZStack { + Circle() + .fill(Color.white) + .shadow(color: Color.black.opacity(0.06), radius: 12, x: 0, y: 2) + .frame(width: 135, height: 135) + + Text(item.categoryName) + .font(.codive_title1) + .foregroundStyle(Color.Codive.grayscale1) + } + } + .frame(width: 240, height: 240) + + if let seg = selectedSegment { + BubbleLabelView( + title: seg.payload ?? "", + percent: selectedPercent + ) + .offset(bubbleOffset) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: selectedID) + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity) + .onAppear { + // 가장 점유율 큰 segment 자동 강조 + 바텀시트 갱신 + let maxSeg = item.items.max(by: { $0.value < $1.value }) + selectedID = maxSeg?.id + if let seg = maxSeg { + onSelectSegment(seg.payload ?? item.categoryName, []) + } + } + .onChange(of: selectedID) { _ in + if let seg = selectedSegment { + onSelectSegment(seg.payload ?? item.categoryName, []) + } + } + } +} + +// MARK: - BubbleLabelView + +private struct BubbleLabelView: View { + let title: String + let percent: Int + + var body: some View { + VStack(spacing: 2) { + Text(title) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + + Text("\(percent)%") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.10), radius: 6, x: 0, y: 3) + ) + } +} + +// MARK: - Preview + +#if DEBUG +private final class PreviewCategoryRepo: StatisticsRepository { + func checkStatisticsCondition() async throws -> Bool { true } + func getFavoriteItems() async throws -> [FavoriteItemPayload] { [] } + + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { + switch categoryId { + case 1: + return [ + FavoriteCategoryItemPayload(categoryId: 11, categoryName: "맨투맨", occupancyRate: 30, clothCount: 5), + FavoriteCategoryItemPayload(categoryId: 12, categoryName: "후드티", occupancyRate: 45, clothCount: 8), + FavoriteCategoryItemPayload(categoryId: 13, categoryName: "셔츠", occupancyRate: 15, clothCount: 3), + FavoriteCategoryItemPayload(categoryId: 14, categoryName: "기타", occupancyRate: 10, clothCount: 2) + ] + case 2: + return [ + FavoriteCategoryItemPayload(categoryId: 21, categoryName: "청바지", occupancyRate: 50, clothCount: 5), + FavoriteCategoryItemPayload(categoryId: 22, categoryName: "면바지", occupancyRate: 30, clothCount: 3), + FavoriteCategoryItemPayload(categoryId: 23, categoryName: "반바지", occupancyRate: 20, clothCount: 2) + ] + case 3: + return [ + FavoriteCategoryItemPayload(categoryId: 31, categoryName: "코트", occupancyRate: 40, clothCount: 4), + FavoriteCategoryItemPayload(categoryId: 32, categoryName: "패딩", occupancyRate: 35, clothCount: 3), + FavoriteCategoryItemPayload(categoryId: 33, categoryName: "자켓", occupancyRate: 25, clothCount: 2) + ] + default: + return [] + } + } + + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + ClosetUtilizationPayload(utilizedCount: 0, unutilizedCount: 0, utilizedClothes: [], unutilizedClothes: []) + } +} + +@MainActor +private func makePreviewVM() -> FavoriteByCategoryViewModel { + let repo = PreviewCategoryRepo() + let vm = FavoriteByCategoryViewModel( + navigationRouter: NavigationRouter(), + fetchFavoriteCategoryItemsUseCase: FetchFavoriteCategoryItemsUseCase(repository: repo), + fetchClothListByCategoryUseCase: FetchClothListByCategoryUseCase(repository: PreviewEmptyClothRepo()), + parentCategoryId: 1 + ) + // .task가 호출되기 전 첫 프레임부터 데이터 있도록 미리 채움 + vm.categories = [ + CategoryFavoriteItem( + parentCategoryId: 1, + categoryName: "상의", + items: [ + DonutSegment(value: 30, color: .Codive.point1, payload: "맨투맨"), + DonutSegment(value: 45, color: .Codive.point2, payload: "후드티"), + DonutSegment(value: 15, color: .Codive.point3, payload: "셔츠"), + DonutSegment(value: 10, color: .Codive.grayscale5, payload: "기타") + ] + ) + ] + // segment별 mock 옷 매핑 — 도넛 탭 시 바텀시트가 갱신됨 + vm.clothesBySegment = [ + "맨투맨": (0..<6).map { _ in + ClothItem(imageUrl: "", brand: "유니클로", name: "Crew neck sweat") + }, + "후드티": (0..<11).map { _ in + ClothItem(imageUrl: "", brand: "나이키", name: "Cable knit cardigan navy color") + }, + "셔츠": (0..<3).map { _ in + ClothItem(imageUrl: "", brand: "무신사", name: "Oxford shirt white") + }, + "기타": (0..<2).map { _ in + ClothItem(imageUrl: "", brand: "아디다스", name: "Polo collar tee") + } + ] + // 첫 진입 시 후드티 자동 선택 + vm.selectSegment(name: "후드티") + return vm +} + +#Preview { + FavoriteByCategoryView(viewModel: makePreviewVM()) +} +#endif diff --git a/Codive/Features/Closet/Presentation/View/report/ItemDataView.swift b/Codive/Features/Closet/Presentation/View/report/ItemDataView.swift new file mode 100644 index 00000000..c22bb001 --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/ItemDataView.swift @@ -0,0 +1,296 @@ +// +// ItemDataView.swift +// Codive +// +// Created by 한태빈 on 12/19/25. +// + +import SwiftUI + +struct ItemDataView: View { + + @StateObject private var viewModel: ItemDataViewModel + @State private var isExpanded: Bool = false + + private let collapsedHeight: CGFloat = 420 + + init(viewModel: ItemDataViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + // 메인 콘텐츠 (차트 상단) + VStack(spacing: 0) { + CustomNavigationBar(title: "수량 통계") { + viewModel.navigateBack() + } + .background(Color.white) + + Text("그래프를 눌러 구체적인 히스토리를 살펴보세요") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 12) + + if viewModel.isLoading { + Spacer() + ProgressView() + .frame(maxWidth: .infinity) + Spacer() + } else if !viewModel.stats.isEmpty { + chartView + .padding(.top, 32) + Spacer(minLength: 0) + // 바텀시트 영역만큼 빈 공간 확보 + Color.clear.frame(height: collapsedHeight - 60) + } else { + Spacer() + } + } + + // 바텀시트 (페이지 하단에 고정) + DataBottomSheet( + title: viewModel.selectedBottomSheetTitle, + totalCount: viewModel.selectedBottomSheetItems.count, + items: viewModel.selectedBottomSheetItems + ) + .frame( + width: geometry.size.width, + height: isExpanded ? geometry.size.height * 0.75 : collapsedHeight + ) + .shadow(color: .black.opacity(0.08), radius: 10, x: 0, y: -4) + .gesture( + DragGesture() + .onEnded { value in + withAnimation(.spring()) { + if value.translation.height < -50 { + isExpanded = true + } else if value.translation.height > 50 { + isExpanded = false + } + } + } + ) + .ignoresSafeArea(.all, edges: .bottom) + } + .background(Color.white) + .toolbar(.hidden, for: .navigationBar) + .task { + await viewModel.loadData() + } + } + .ignoresSafeArea(.all, edges: .bottom) + } + + // MARK: - Chart + + private var chartView: some View { + let chartHeight: CGFloat = 200 + let barCornerRadius: CGFloat = 12 + let barSpacing: CGFloat = 18 + let minBarHeight: CGFloat = 12 + let maxCount = max(viewModel.stats.map(\.usageCount).max() ?? 1, 1) + // 점선 기준값: 선택된 막대 높이 (없으면 최대값) + let dashValue: Int = { + if let idx = viewModel.selectedIndex, + viewModel.stats.indices.contains(idx) { + return viewModel.stats[idx].usageCount + } + return viewModel.stats.map(\.usageCount).max() ?? 1 + }() + + return VStack(spacing: 0) { + GeometryReader { geo in + let width = geo.size.width + let count = max(viewModel.stats.count, 1) + let barWidth = (width - barSpacing * CGFloat(count - 1)) / CGFloat(count) + + ZStack(alignment: .bottomLeading) { + // 막대들 + HStack(alignment: .bottom, spacing: barSpacing) { + ForEach(Array(viewModel.stats.enumerated()), id: \.offset) { idx, item in + let barHeight = max( + minBarHeight, + CGFloat(item.usageCount) / CGFloat(maxCount) * chartHeight + ) + let isSelected = viewModel.selectedIndex == idx + + RoundedTopRectangle(cornerRadius: barCornerRadius) + .fill(isSelected ? Color.Codive.point1 : Color.Codive.grayscale6) + .frame(width: barWidth, height: barHeight) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + viewModel.selectBar(at: idx) + } + } + } + } + + // 점선: 선택된 막대 높이 기준 — 막대 위에 그려짐 + let dashY = chartHeight - (CGFloat(dashValue) / CGFloat(maxCount) * chartHeight) + Path { path in + path.move(to: CGPoint(x: 0, y: dashY)) + path.addLine(to: CGPoint(x: width, y: dashY)) + } + .stroke( + Color.Codive.point1, + style: StrokeStyle(lineWidth: 1, dash: [4, 4]) + ) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: dashValue) + .zIndex(5) + + // 강조 막대 위 카운트 박스 + if let selectedIndex = viewModel.selectedIndex, + viewModel.stats.indices.contains(selectedIndex) { + let selectedValue = viewModel.stats[selectedIndex].usageCount + let selectedHeight = CGFloat(selectedValue) / CGFloat(maxCount) * chartHeight + let yPosition = chartHeight - selectedHeight + let centerX = (barWidth / 2) + (barWidth + barSpacing) * CGFloat(selectedIndex) + + CountBadge(text: "\(selectedValue)벌") + .position(x: centerX, y: yPosition - 18) + .zIndex(10) + } + } + } + .frame(height: chartHeight) + .padding(.horizontal, 24) + .padding(.top, 32) + + // 카테고리 이름 + HStack(alignment: .top, spacing: 18) { + ForEach(Array(viewModel.stats.enumerated()), id: \.offset) { _, item in + Text(item.itemName) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale3) + .lineLimit(1) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 24) + .padding(.top, 12) + } + } +} + +// MARK: - CountBadge + +private struct CountBadge: View { + let text: String + + var body: some View { + Text(text) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3) + ) + } +} + +// MARK: - RoundedTopRectangle + +struct RoundedTopRectangle: Shape { + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + let radius = min(cornerRadius, rect.width / 2, rect.height / 2) + + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius)) + path.addArc( + center: CGPoint(x: rect.minX + radius, y: rect.minY + radius), + radius: radius, + startAngle: .degrees(180), + endAngle: .degrees(270), + clockwise: false + ) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY)) + path.addArc( + center: CGPoint(x: rect.maxX - radius, y: rect.minY + radius), + radius: radius, + startAngle: .degrees(270), + endAngle: .degrees(0), + clockwise: false + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + + return path + } +} + +// MARK: - Preview + +#if DEBUG +private final class PreviewItemRepo: StatisticsRepository { + func checkStatisticsCondition() async throws -> Bool { true } + + func getFavoriteItems() async throws -> [FavoriteItemPayload] { + [ + FavoriteItemPayload(categoryId: 1, categoryName: "맨투맨", clothCount: 10), + FavoriteItemPayload(categoryId: 2, categoryName: "원피스", clothCount: 8), + FavoriteItemPayload(categoryId: 3, categoryName: "니트", clothCount: 5), + FavoriteItemPayload(categoryId: 4, categoryName: "후드티", clothCount: 3), + FavoriteItemPayload(categoryId: 5, categoryName: "레깅스", clothCount: 2) + ] + } + + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { [] } + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + ClosetUtilizationPayload(utilizedCount: 0, unutilizedCount: 0, utilizedClothes: [], unutilizedClothes: []) + } +} + +@MainActor +private func makePreviewVM(selectedIndex: Int = 1) -> ItemDataViewModel { + let repo = PreviewItemRepo() + let vm = ItemDataViewModel( + navigationRouter: NavigationRouter(), + fetchFavoriteItemsUseCase: FetchFavoriteItemsUseCase(repository: repo), + fetchClothListByCategoryUseCase: FetchClothListByCategoryUseCase(repository: PreviewEmptyClothRepo()) + ) + vm.stats = [ + ItemUsageStat(itemName: "맨투맨", usageCount: 10), + ItemUsageStat(itemName: "원피스", usageCount: 8), + ItemUsageStat(itemName: "니트", usageCount: 5), + ItemUsageStat(itemName: "후드티", usageCount: 3), + ItemUsageStat(itemName: "레깅스", usageCount: 2) + ] + vm.clothesByItem = [ + "맨투맨": (0..<10).map { _ in + ClothItem(imageUrl: "", brand: "유니클로", name: "Crew neck sweat") + }, + "원피스": (0..<11).map { _ in + ClothItem(imageUrl: "", brand: "나이키", name: "Cable knit cardigan navy color") + }, + "니트": (0..<11).map { _ in + ClothItem(imageUrl: "", brand: "무신사", name: "Wool blend knit beige") + }, + "후드티": (0..<3).map { _ in + ClothItem(imageUrl: "", brand: "아디다스", name: "Pullover hoodie black") + }, + "레깅스": (0..<2).map { _ in + ClothItem(imageUrl: "", brand: "젝시믹스", name: "Tight leggings") + } + ] + vm.selectBar(at: selectedIndex) + return vm +} + +#Preview("디자인 매칭 - 원피스 강조") { + ItemDataView(viewModel: makePreviewVM(selectedIndex: 1)) +} + +#Preview("니트 강조") { + ItemDataView(viewModel: makePreviewVM(selectedIndex: 2)) +} +#endif diff --git a/Codive/Features/Closet/Presentation/View/report/ItemStatsSection.swift b/Codive/Features/Closet/Presentation/View/report/ItemStatsSection.swift new file mode 100644 index 00000000..31d59917 --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/ItemStatsSection.swift @@ -0,0 +1,64 @@ +// +// ItemStatsSection.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct ItemStatsSection: View { + + let stats: [ItemUsageStat] + @Binding var showingTooltip: String? + let onTap: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ReportSectionHeader( + title: "옷장 아이템 통계", + tooltip: "전체 아이템 중 많이 보유한 아이템\nTOP 5를 확인할 수 있는 그래프입니다", + showingTooltip: $showingTooltip + ) + + ReportCardContainer { + ItemBarChart(stats: stats, maxBarHeight: 140) + } + .onTapGesture { onTap() } + } + } +} + +// MARK: - Preview + +#Preview("디자인 매칭") { + ScrollView { + ItemStatsSection( + stats: [ + ItemUsageStat(itemName: "맨투맨", usageCount: 8), + ItemUsageStat(itemName: "원피스", usageCount: 6), + ItemUsageStat(itemName: "니트", usageCount: 4), + ItemUsageStat(itemName: "후드티", usageCount: 3), + ItemUsageStat(itemName: "레깅스", usageCount: 2) + ], + showingTooltip: .constant(nil) + ) {} + } + .background(Color.Codive.grayscale7) +} + +#Preview("값 차이 큰 케이스") { + ScrollView { + ItemStatsSection( + stats: [ + ItemUsageStat(itemName: "티셔츠", usageCount: 20), + ItemUsageStat(itemName: "셔츠", usageCount: 5), + ItemUsageStat(itemName: "니트", usageCount: 3), + ItemUsageStat(itemName: "후드티", usageCount: 2), + ItemUsageStat(itemName: "맨투맨", usageCount: 1) + ], + showingTooltip: .constant(nil) + ) {} + } + .background(Color.Codive.grayscale7) +} diff --git a/Codive/Features/Closet/Presentation/View/report/Preview/PreviewEmptyClothRepo.swift b/Codive/Features/Closet/Presentation/View/report/Preview/PreviewEmptyClothRepo.swift new file mode 100644 index 00000000..b74fb1c1 --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/Preview/PreviewEmptyClothRepo.swift @@ -0,0 +1,49 @@ +// +// PreviewEmptyClothRepo.swift +// Codive +// +// Created by 황상환 on 5/6/26. +// + +#if DEBUG +import CodiveAPI +import Foundation + +/// 리포트 관련 SwiftUI 프리뷰에서 ViewModel 의존성 주입용 빈 ClothRepository. +/// 모든 메서드가 빈 응답을 반환하므로 옷 조회 호출이 일어나도 뷰가 깨지지 않음. +final class PreviewEmptyClothRepo: ClothRepository { + func fetchClothItems(category: String?) async throws -> [ProductItem] { [] } + func saveClothes(_ inputs: [ClothInput], images: [Data]) async throws -> [Cloth] { [] } + func fetchMyClosetClothItems( + mainCategory: String?, + subCategory: String?, + seasons: Set, + searchText: String? + ) async throws -> [Cloth] { [] } + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) { ([], true) } + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { + ClothDetailResult( + clothImageUrl: "", + parentCategory: nil, + category: nil, + name: nil, + brand: nil, + clothUrl: nil, + seasons: [] + ) + } + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws {} + func deleteCloth(clothId: Int) async throws {} + func deleteClothItems(_ clothIds: [Int]) async throws {} + func fetchLookBookList( + lastLookBookId: Int64?, + size: Int32, + direction: Operations.LookBook_getLookBooks.Input.Query.directionPayload + ) async throws -> (content: [LookBookEntity], isLast: Bool) { ([], true) } +} +#endif diff --git a/Codive/Features/Closet/Presentation/View/report/UsageCheckSection.swift b/Codive/Features/Closet/Presentation/View/report/UsageCheckSection.swift new file mode 100644 index 00000000..32bc3a89 --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/UsageCheckSection.swift @@ -0,0 +1,102 @@ +// +// UsageCheckSection.swift +// Codive +// +// Created by 황상환 on 5/5/26. +// + +import SwiftUI + +struct UsageCheckSection: View { + + let usage: WardrobeUsageStat + var topItemName: String? + @Binding var showingTooltip: String? + let onTap: () -> Void + + private var infoText: String { + if let name = topItemName, !name.isEmpty { + return "\(name)이 가장 많으며\n총 \(usage.wornCount)번 착용했습니다" + } + return "총 \(usage.totalCount)벌 중\n\(usage.wornCount)번 착용했습니다" + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ReportSectionHeader( + title: "옷장 활용도 체크", + tooltip: "코디 결정하기와 기록에서 태그한 옷을 기반으로\n보관 중인 옷 중 실제 착용한 비율을 보여주는 그래프입니다", + showingTooltip: $showingTooltip + ) + + ReportCardContainer { + HStack(alignment: .center, spacing: 0) { + VStack(spacing: 8) { + UsageDonutChart(stats: usage) + .frame(width: 130, height: 130) + + (Text("(\(usage.wornCount)벌").foregroundColor(Color.Codive.point1) + + Text(" / \(usage.totalCount)벌)").foregroundColor(Color.Codive.grayscale3)) + .font(.codive_body3_medium) + } + + // 도넛 우측 영역의 가로 가운데에 텍스트 정렬 + Text(infoText) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .lineSpacing(4) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 4) + } + .onTapGesture { onTap() } + } + } +} + +// MARK: - Preview + +#Preview("디자인 매칭 - 40%") { + ScrollView { + UsageCheckSection( + usage: WardrobeUsageStat(totalCount: 20, wornCount: 8), + topItemName: "나시", + showingTooltip: .constant(nil) + ) {} + } + .background(Color.Codive.grayscale7) +} + +#Preview("70% 활용") { + ScrollView { + UsageCheckSection( + usage: WardrobeUsageStat(totalCount: 30, wornCount: 21), + topItemName: "맨투맨", + showingTooltip: .constant(nil) + ) {} + } + .background(Color.Codive.grayscale7) +} + +#Preview("100% 활용") { + ScrollView { + UsageCheckSection( + usage: WardrobeUsageStat(totalCount: 15, wornCount: 15), + topItemName: "셔츠", + showingTooltip: .constant(nil) + ) {} + } + .background(Color.Codive.grayscale7) +} + +#Preview("topItem 없음") { + ScrollView { + UsageCheckSection( + usage: WardrobeUsageStat(totalCount: 50, wornCount: 30), + showingTooltip: .constant(nil) + ) {} + } + .background(Color.Codive.grayscale7) +} diff --git a/Codive/Features/Closet/Presentation/View/report/WardrobeReportDetailView.swift b/Codive/Features/Closet/Presentation/View/report/WardrobeReportDetailView.swift index c59a9942..4f6d6ca5 100644 --- a/Codive/Features/Closet/Presentation/View/report/WardrobeReportDetailView.swift +++ b/Codive/Features/Closet/Presentation/View/report/WardrobeReportDetailView.swift @@ -11,6 +11,7 @@ struct WardrobeReportDetailView: View { // MARK: - Properties @StateObject private var viewModel: WardrobeReportDetailViewModel + @State private var showingTooltip: String? // MARK: - Initializer init(viewModel: WardrobeReportDetailViewModel) { @@ -19,34 +20,49 @@ struct WardrobeReportDetailView: View { // MARK: - Body var body: some View { - VStack(spacing: 0) { - CustomNavigationBar( - title: "\(viewModel.currentMonth)월 옷장 리포트" - ) { - viewModel.navigateBack() + ZStack { + VStack(spacing: 0) { + if viewModel.isLoading { + loadingView + } else if !viewModel.canAggregate { + insufficientDataView + } else { + reportContentView + } } - - if viewModel.isLoading { - Spacer() - ProgressView() - Spacer() - } else if !viewModel.canAggregate { - insufficientDataView - } else { - // TODO: 통계 데이터 표시 (추후 구현) - Spacer() + .background(Color.white) + .contentShape(Rectangle()) + .onTapGesture { + if showingTooltip != nil { + withAnimation(.easeInOut(duration: 0.2)) { + showingTooltip = nil + } + } + } + } + .overlayPreferenceValue(TooltipAnchorKey.self) { tooltipAnchor in + GeometryReader { proxy in + if let tooltipAnchor { + let rect = proxy[tooltipAnchor.anchor] + TooltipBubbleView(text: tooltipAnchor.text) + .frame(maxWidth: proxy.size.width - 40, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .offset(x: 20, y: rect.maxY + 8) + .transition(.opacity) + } } + .allowsHitTesting(false) } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .task { - await viewModel.checkCondition() + await viewModel.loadReport() } .alert( "네트워크 오류", isPresented: $viewModel.isShowingNetworkErrorAlert ) { Button("재시도") { - Task { await viewModel.checkCondition() } + Task { await viewModel.loadReport() } } Button("확인", role: .cancel) { } } message: { @@ -54,9 +70,29 @@ struct WardrobeReportDetailView: View { } } + // MARK: - Loading View + private var loadingView: some View { + VStack { + CustomNavigationBar( + title: "\(viewModel.currentMonth)월 옷장 리포트" + ) { + viewModel.navigateBack() + } + Spacer() + ProgressView() + Spacer() + } + } + // MARK: - Insufficient Data View private var insufficientDataView: some View { VStack(spacing: 8) { + CustomNavigationBar( + title: "\(viewModel.currentMonth)월 옷장 리포트" + ) { + viewModel.navigateBack() + } + Spacer() Text("리포트를 완성하기엔\n히스토리가 적어요") @@ -81,5 +117,133 @@ struct WardrobeReportDetailView: View { Spacer() } .padding(.horizontal, 20) + .background(Color.white) + } + + // MARK: - Report Content View + private var reportContentView: some View { + VStack(spacing: 0) { + CustomNavigationBar( + title: "\(viewModel.currentMonth)월 옷장 리포트" + ) { + viewModel.navigateBack() + } + .background(Color.white) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.dateRangeString) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + .padding(.top, 4) + + FavoriteByCategorySection( + categories: viewModel.favoriteCategories, + showingTooltip: $showingTooltip + ) { categoryId in + viewModel.navigateToFavoriteCategory(parentCategoryId: categoryId) + } + .padding(.top, 40) + + ItemStatsSection( + stats: viewModel.favoriteItems, + showingTooltip: $showingTooltip + ) { + viewModel.navigateToItemStats() + } + .padding(.top, 40) + + UsageCheckSection( + usage: viewModel.wardrobeUsage, + topItemName: viewModel.topUtilizedItemName, + showingTooltip: $showingTooltip + ) { + viewModel.navigateToUsageCheck() + } + .padding(.top, 40) + + Spacer(minLength: 24) + } + } + } + } + +} + +// MARK: - Preview + +private final class PreviewStatisticsRepository: StatisticsRepository { + let canAggregate: Bool + init(canAggregate: Bool = true) { + self.canAggregate = canAggregate } + + func checkStatisticsCondition() async throws -> Bool { canAggregate } + + func getFavoriteItems() async throws -> [FavoriteItemPayload] { + [ + FavoriteItemPayload(categoryId: 1, categoryName: "맨투맨", clothCount: 8), + FavoriteItemPayload(categoryId: 2, categoryName: "원피스", clothCount: 6), + FavoriteItemPayload(categoryId: 3, categoryName: "니트", clothCount: 4), + FavoriteItemPayload(categoryId: 4, categoryName: "후드티", clothCount: 3), + FavoriteItemPayload(categoryId: 5, categoryName: "레깅스", clothCount: 2) + ] + } + + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { + switch categoryId { + case 1: + return [ + FavoriteCategoryItemPayload(categoryId: 11, categoryName: "맨투맨", occupancyRate: 45, clothCount: 8), + FavoriteCategoryItemPayload(categoryId: 12, categoryName: "후드티", occupancyRate: 30, clothCount: 5), + FavoriteCategoryItemPayload(categoryId: 13, categoryName: "셔츠", occupancyRate: 15, clothCount: 3), + FavoriteCategoryItemPayload(categoryId: 14, categoryName: "기타", occupancyRate: 10, clothCount: 2) + ] + case 2: + return [ + FavoriteCategoryItemPayload(categoryId: 21, categoryName: "청바지", occupancyRate: 60, clothCount: 6), + FavoriteCategoryItemPayload(categoryId: 22, categoryName: "면바지", occupancyRate: 25, clothCount: 3), + FavoriteCategoryItemPayload(categoryId: 23, categoryName: "반바지", occupancyRate: 15, clothCount: 1) + ] + default: + return [] + } + } + + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + ClosetUtilizationPayload( + utilizedCount: 8, + unutilizedCount: 12, + utilizedClothes: [ + ClosetUtilizationClothPayload(imageUrl: "", name: "나시", brand: "Brand"), + ClosetUtilizationClothPayload(imageUrl: "", name: "맨투맨", brand: "Brand") + ], + unutilizedClothes: [ + ClosetUtilizationClothPayload(imageUrl: "", name: "셔츠", brand: "Brand") + ] + ) + } +} + +@MainActor +private func makePreviewViewModel(canAggregate: Bool = true) -> WardrobeReportDetailViewModel { + let repo = PreviewStatisticsRepository(canAggregate: canAggregate) + return WardrobeReportDetailViewModel( + navigationRouter: NavigationRouter(), + checkStatisticsConditionUseCase: CheckStatisticsConditionUseCase(repository: repo), + fetchFavoriteItemsUseCase: FetchFavoriteItemsUseCase(repository: repo), + fetchFavoriteCategoryItemsUseCase: FetchFavoriteCategoryItemsUseCase(repository: repo), + fetchClosetUtilizationUseCase: FetchClosetUtilizationUseCase(repository: repo) + ) +} + +#Preview("리포트 - 데이터 있음") { + WardrobeReportDetailView(viewModel: makePreviewViewModel()) +} + +#Preview("리포트 - 데이터 부족") { + WardrobeReportDetailView(viewModel: makePreviewViewModel(canAggregate: false)) } diff --git a/Codive/Features/Closet/Presentation/View/report/WearingDataView.swift b/Codive/Features/Closet/Presentation/View/report/WearingDataView.swift new file mode 100644 index 00000000..6d6e41dd --- /dev/null +++ b/Codive/Features/Closet/Presentation/View/report/WearingDataView.swift @@ -0,0 +1,198 @@ +// +// WearingDataView.swift +// Codive +// +// Created by 한태빈 on 12/19/25. +// + +import SwiftUI + +struct WearingDataView: View { + + @StateObject private var viewModel: WearingDataViewModel + @State private var isExpanded: Bool = false + + private let collapsedHeight: CGFloat = 420 + + init(viewModel: WearingDataViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + // 메인 콘텐츠 (도넛 상단) + VStack(spacing: 0) { + CustomNavigationBar(title: "활용도 체크") { + viewModel.navigateBack() + } + .background(Color.white) + + if viewModel.isLoading { + Spacer() + ProgressView() + .frame(maxWidth: .infinity) + Spacer() + } else { + Text(viewModel.subtitleText) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .lineSpacing(4) + .padding(.top, 12) + + usageChart + .padding(.top, 28) + + Spacer(minLength: 0) + // 바텀시트 영역 확보 + Color.clear.frame(height: collapsedHeight - 60) + } + } + + // 바텀시트 (페이지 하단에 고정) + DataBottomSheet( + title: viewModel.selectedBottomSheetTitle, + totalCount: viewModel.stats.totalCount, + items: viewModel.selectedBottomSheetItems + ) + .frame( + width: geometry.size.width, + height: isExpanded ? geometry.size.height * 0.75 : collapsedHeight + ) + .shadow(color: .black.opacity(0.08), radius: 10, x: 0, y: -4) + .gesture( + DragGesture() + .onEnded { value in + withAnimation(.spring()) { + if value.translation.height < -50 { + isExpanded = true + } else if value.translation.height > 50 { + isExpanded = false + } + } + } + ) + .ignoresSafeArea(.all, edges: .bottom) + } + .background(Color.white) + .toolbar(.hidden, for: .navigationBar) + .task { + await viewModel.loadData() + } + } + .ignoresSafeArea(.all, edges: .bottom) + } + + // MARK: - Usage Chart + + private var usageChart: some View { + let safeTotal = max(0, viewModel.stats.totalCount) + let safeWorn = max(0, min(viewModel.stats.wornCount, safeTotal)) + let notWorn = max(0, safeTotal - safeWorn) + let isWornSelected = viewModel.selectedPayload == "입음" + + let segments: [DonutSegment] = [ + DonutSegment( + value: Double(safeWorn), + color: isWornSelected ? Color.Codive.point1 : Color.Codive.point4, + payload: "입음" + ), + DonutSegment( + value: Double(notWorn), + color: isWornSelected ? Color.Codive.point4 : Color.Codive.point1, + payload: "미착용" + ) + ] + + // selectedPayload로부터 ID를 매번 동기화 + let selectedIDBinding = Binding( + get: { segments.first(where: { $0.payload == viewModel.selectedPayload })?.id }, + set: { newID in + if let payload = segments.first(where: { $0.id == newID })?.payload { + viewModel.selectSegment(payload: payload) + } + } + ) + + return DonutChartView( + segments: segments, + selectedID: selectedIDBinding, + thickness: 50, + gapDegrees: 0, + cornerRadius: 8, + selectedOuterExtension: 6, + selectedInnerExtension: 4, + selectedAngularInset: 1.5 + ) { + ZStack { + Circle() + .fill(Color.white) + .shadow(color: Color.black.opacity(0.06), radius: 12, x: 0, y: 2) + .frame(width: 130, height: 130) + + Text("\(viewModel.centerPercent)%") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Color.Codive.point1) + } + } + .frame(width: 240, height: 240) + } +} + +// MARK: - Preview + +private final class PreviewWearingRepo: StatisticsRepository { + let utilized: Int + let unutilized: Int + + init(utilized: Int, unutilized: Int) { + self.utilized = utilized + self.unutilized = unutilized + } + + func checkStatisticsCondition() async throws -> Bool { true } + func getFavoriteItems() async throws -> [FavoriteItemPayload] { [] } + func getFavoriteCategoryItems(categoryId: Int64) async throws -> [FavoriteCategoryItemPayload] { [] } + + func getClosetUtilization(season: String) async throws -> ClosetUtilizationPayload { + ClosetUtilizationPayload( + utilizedCount: utilized, + unutilizedCount: unutilized, + utilizedClothes: (0.. WearingDataViewModel { + let repo = PreviewWearingRepo(utilized: 8, unutilized: 12) + let vm = WearingDataViewModel( + navigationRouter: NavigationRouter(), + fetchClosetUtilizationUseCase: FetchClosetUtilizationUseCase(repository: repo) + ) + // 첫 프레임부터 데이터 있도록 미리 채움 + vm.stats = WardrobeUsageStat(totalCount: 20, wornCount: 8) + vm.utilizedClothes = (0..<8).map { _ in + ClothItem(imageUrl: "", brand: "나이키", name: "Cable knit cardigan navy color") + } + vm.unutilizedClothes = (0..<12).map { _ in + ClothItem(imageUrl: "", brand: "나이키", name: "Cable knit cardigan navy color") + } + vm.selectSegment(payload: initialPayload) + return vm +} + +#Preview("입음 강조 (40%)") { + WearingDataView(viewModel: makePreviewVM(initialPayload: "입음")) +} + +#Preview("미착용 강조 (60%)") { + WearingDataView(viewModel: makePreviewVM(initialPayload: "미착용")) +} diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift index 0ffe1d52..19fd0166 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift @@ -329,16 +329,16 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd applyAIResults(uploadResults: uploadResults, aiInfos: aiInfos) // 4. 결과 메시지 - let uploadSuccessCount = successUrls.count + let bgRemoveSuccessCount = aiInfos.filter { !$0.info.clothImageUrl.isEmpty }.count var messages: [String] = [] - if uploadSuccessCount == totalCount { - messages.append("배경 제거: \(uploadSuccessCount)장 성공") - } else if uploadSuccessCount == 0 { + if bgRemoveSuccessCount == totalCount { + messages.append("배경 제거: \(bgRemoveSuccessCount)장 성공") + } else if bgRemoveSuccessCount == 0 { messages.append("배경 제거: 실패") } else { - let failCount = totalCount - uploadSuccessCount - messages.append("배경 제거: \(uploadSuccessCount)장 성공, \(failCount)장 실패") + let failCount = totalCount - bgRemoveSuccessCount + messages.append("배경 제거: \(bgRemoveSuccessCount)장 성공, \(failCount)장 실패") } if aiInfos.isEmpty { diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift index 82c66579..156fd548 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift @@ -74,7 +74,7 @@ final class ClothDetailViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let deleteClothItemsUseCase: DeleteClothItemsUseCase - private let clothRepository: ClothRepository + private let fetchClothDetailUseCase: FetchClothDetailUseCase // MARK: - Initializer @@ -82,12 +82,12 @@ final class ClothDetailViewModel: ObservableObject { cloth: Cloth, navigationRouter: NavigationRouter, deleteClothItemsUseCase: DeleteClothItemsUseCase, - clothRepository: ClothRepository + fetchClothDetailUseCase: FetchClothDetailUseCase ) { self.cloth = cloth self.navigationRouter = navigationRouter self.deleteClothItemsUseCase = deleteClothItemsUseCase - self.clothRepository = clothRepository + self.fetchClothDetailUseCase = fetchClothDetailUseCase } // MARK: - Fetch Detail @@ -95,7 +95,7 @@ final class ClothDetailViewModel: ObservableObject { func fetchDetail() async { isLoading = true do { - let result = try await clothRepository.fetchClothDetail(clothId: cloth.id) + let result = try await fetchClothDetailUseCase.execute(clothId: cloth.id) detailData = result } catch { // 에러는 무시 (UI에서 기존 데이터 사용) diff --git a/Codive/Features/Closet/Presentation/ViewModel/FavoriteByCategoryViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/FavoriteByCategoryViewModel.swift new file mode 100644 index 00000000..c44637e0 --- /dev/null +++ b/Codive/Features/Closet/Presentation/ViewModel/FavoriteByCategoryViewModel.swift @@ -0,0 +1,128 @@ +// +// FavoriteByCategoryViewModel.swift +// Codive +// +// Created by 황상환 on 5/3/26. +// + +import Foundation +import SwiftUI + +@MainActor +final class FavoriteByCategoryViewModel: ObservableObject { + + // MARK: - Published Properties + @Published var isLoading: Bool = false + @Published var categories: [CategoryFavoriteItem] = [] + @Published var selectedBottomSheetTitle: String = "" + @Published var selectedBottomSheetItems: [ClothItem] = [] + + /// segment 이름 → 해당 옷 목록 매핑 (탭 시 바텀시트 갱신용 캐시) + var clothesBySegment: [String: [ClothItem]] = [:] + + /// segment 이름 → categoryId 매핑 (옷 조회 API 호출용) + private var segmentNameToCategoryId: [String: Int64] = [:] + + func selectSegment(name: String) { + selectedBottomSheetTitle = name + + // 캐시된 옷 목록이 있으면 즉시 표시 + if let cached = clothesBySegment[name] { + selectedBottomSheetItems = cached + return + } + + // 비어있으면 로딩 상태로 표시 후 비동기 조회 + selectedBottomSheetItems = [] + guard let categoryId = segmentNameToCategoryId[name] else { return } + + Task { + await fetchClothes(name: name, categoryId: categoryId) + } + } + + private func fetchClothes(name: String, categoryId: Int64) async { + do { + let clothes = try await fetchClothListByCategoryUseCase.execute( + categoryId: Int(categoryId) + ) + let items = clothes.map { ClothItem(from: $0) } + clothesBySegment[name] = items + // 사용자가 그 사이 다른 segment를 선택했을 수 있으니 현재 선택 일치할 때만 업데이트 + if selectedBottomSheetTitle == name { + selectedBottomSheetItems = items + } + } catch { + #if DEBUG + print("[FavoriteByCategory] 옷 조회 실패 - \(name)(id: \(categoryId)), error: \(error)") + #endif + } + } + + // MARK: - Private Properties + private let navigationRouter: NavigationRouter + private let fetchFavoriteCategoryItemsUseCase: FetchFavoriteCategoryItemsUseCase + private let fetchClothListByCategoryUseCase: FetchClothListByCategoryUseCase + private let parentCategoryId: Int64 + + // MARK: - Initializer + init( + navigationRouter: NavigationRouter, + fetchFavoriteCategoryItemsUseCase: FetchFavoriteCategoryItemsUseCase, + fetchClothListByCategoryUseCase: FetchClothListByCategoryUseCase, + parentCategoryId: Int64 + ) { + self.navigationRouter = navigationRouter + self.fetchFavoriteCategoryItemsUseCase = fetchFavoriteCategoryItemsUseCase + self.fetchClothListByCategoryUseCase = fetchClothListByCategoryUseCase + self.parentCategoryId = parentCategoryId + } + + // MARK: - Methods + func loadData() async { + isLoading = true + let colors: [Color] = [.Codive.point1, .Codive.point2, .Codive.point3, .Codive.grayscale5] + + // 진입 시 받은 부모 카테고리 1개만 조회 + let parentName = CategoryConstants.all + .first(where: { Int64($0.id) == parentCategoryId })?.name ?? "" + + var result: [CategoryFavoriteItem] = [] + var idMapping: [String: Int64] = [:] + do { + let details = try await fetchFavoriteCategoryItemsUseCase.execute( + categoryId: parentCategoryId + ) + if !details.isEmpty { + let segments = details.enumerated().map { index, detail in + if let id = detail.categoryId { + idMapping[detail.categoryName] = id + } + return DonutSegment( + value: detail.occupancyRate, + color: colors[min(index, colors.count - 1)], + payload: detail.categoryName + ) + } + result.append( + CategoryFavoriteItem( + parentCategoryId: parentCategoryId, + categoryName: parentName, + items: segments + ) + ) + } + } catch { + #if DEBUG + print("[FavoriteByCategory] \(parentName)(id: \(parentCategoryId)) 조회 실패: \(error)") + #endif + } + self.categories = result + self.segmentNameToCategoryId = idMapping + isLoading = false + } + + func navigateBack() { + navigationRouter.navigateBack() + } +} diff --git a/Codive/Features/Closet/Presentation/ViewModel/ItemDataViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ItemDataViewModel.swift new file mode 100644 index 00000000..76e68ddd --- /dev/null +++ b/Codive/Features/Closet/Presentation/ViewModel/ItemDataViewModel.swift @@ -0,0 +1,107 @@ +// +// ItemDataViewModel.swift +// Codive +// +// Created by 황상환 on 5/3/26. +// + +import Foundation + +@MainActor +final class ItemDataViewModel: ObservableObject { + + // MARK: - Published Properties + @Published var isLoading: Bool = false + @Published var stats: [ItemUsageStat] = [] + @Published var selectedIndex: Int? + @Published var selectedBottomSheetTitle: String = "" + @Published var selectedBottomSheetItems: [ClothItem] = [] + + /// 아이템 이름 → 옷 목록 매핑 (탭 시 바텀시트 갱신용 캐시) + var clothesByItem: [String: [ClothItem]] = [:] + + /// 아이템 이름 → categoryId 매핑 (옷 조회 API 호출용) + private var itemNameToCategoryId: [String: Int64] = [:] + + // MARK: - Private Properties + private let navigationRouter: NavigationRouter + private let fetchFavoriteItemsUseCase: FetchFavoriteItemsUseCase + private let fetchClothListByCategoryUseCase: FetchClothListByCategoryUseCase + + // MARK: - Initializer + init( + navigationRouter: NavigationRouter, + fetchFavoriteItemsUseCase: FetchFavoriteItemsUseCase, + fetchClothListByCategoryUseCase: FetchClothListByCategoryUseCase + ) { + self.navigationRouter = navigationRouter + self.fetchFavoriteItemsUseCase = fetchFavoriteItemsUseCase + self.fetchClothListByCategoryUseCase = fetchClothListByCategoryUseCase + } + + // MARK: - Methods + func loadData() async { + isLoading = true + do { + let items = try await fetchFavoriteItemsUseCase.execute() + self.stats = items.map { + ItemUsageStat(itemName: $0.categoryName, usageCount: $0.clothCount) + } + self.itemNameToCategoryId = items.reduce(into: [String: Int64]()) { dict, payload in + if let id = payload.categoryId { + dict[payload.categoryName] = id + } + } + if !stats.isEmpty { + selectBar(at: 0) + } + } catch { + #if DEBUG + print("[ItemData] 데이터 로드 실패: \(error)") + #endif + } + isLoading = false + } + + func selectBar(at index: Int) { + selectedIndex = index + guard stats.indices.contains(index) else { return } + let item = stats[index] + selectedBottomSheetTitle = item.itemName + + // 캐시된 옷 목록이 있으면 즉시 표시 + if let cached = clothesByItem[item.itemName] { + selectedBottomSheetItems = cached + return + } + + // 비어있으면 로딩 상태로 표시 후 비동기 조회 + selectedBottomSheetItems = [] + guard let categoryId = itemNameToCategoryId[item.itemName] else { return } + + Task { + await fetchClothes(name: item.itemName, categoryId: categoryId) + } + } + + private func fetchClothes(name: String, categoryId: Int64) async { + do { + let clothes = try await fetchClothListByCategoryUseCase.execute( + categoryId: Int(categoryId) + ) + let items = clothes.map { ClothItem(from: $0) } + clothesByItem[name] = items + if selectedBottomSheetTitle == name { + selectedBottomSheetItems = items + } + } catch { + #if DEBUG + print("[ItemData] 옷 조회 실패 - \(name)(id: \(categoryId)), error: \(error)") + #endif + } + } + + func navigateBack() { + navigationRouter.navigateBack() + } +} diff --git a/Codive/Features/Closet/Presentation/ViewModel/WardrobeReportDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/WardrobeReportDetailViewModel.swift index 201c8139..f4a60351 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/WardrobeReportDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/WardrobeReportDetailViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import SwiftUI @MainActor final class WardrobeReportDetailViewModel: ObservableObject { @@ -16,29 +17,62 @@ final class WardrobeReportDetailViewModel: ObservableObject { @Published var isShowingNetworkErrorAlert: Bool = false @Published var networkErrorMessage: String? + // 통계 데이터 + @Published var favoriteItems: [ItemUsageStat] = [] + @Published var favoriteCategories: [CategoryFavoriteItem] = [] + @Published var wardrobeUsage: WardrobeUsageStat = WardrobeUsageStat(totalCount: 0, wornCount: 0) + /// 활용도 체크 카드의 부제에 표시할 가장 많이 입은 옷 이름 + @Published var topUtilizedItemName: String? + // MARK: - Computed Properties var currentMonth: Int { Calendar.current.component(.month, from: Date()) } + var dateRangeString: String { + let now = Date() + let calendar = Calendar.current + let oneMonthAgo = calendar.date(byAdding: .month, value: -1, to: now) ?? now + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd" + return "\(formatter.string(from: oneMonthAgo)) ~ \(formatter.string(from: now))" + } + + var currentSeason: String { + Season.current.rawValue + } + // MARK: - Private Properties private let navigationRouter: NavigationRouter private let checkStatisticsConditionUseCase: CheckStatisticsConditionUseCase + private let fetchFavoriteItemsUseCase: FetchFavoriteItemsUseCase + private let fetchFavoriteCategoryItemsUseCase: FetchFavoriteCategoryItemsUseCase + private let fetchClosetUtilizationUseCase: FetchClosetUtilizationUseCase // MARK: - Initializer init( navigationRouter: NavigationRouter, - checkStatisticsConditionUseCase: CheckStatisticsConditionUseCase + checkStatisticsConditionUseCase: CheckStatisticsConditionUseCase, + fetchFavoriteItemsUseCase: FetchFavoriteItemsUseCase, + fetchFavoriteCategoryItemsUseCase: FetchFavoriteCategoryItemsUseCase, + fetchClosetUtilizationUseCase: FetchClosetUtilizationUseCase ) { self.navigationRouter = navigationRouter self.checkStatisticsConditionUseCase = checkStatisticsConditionUseCase + self.fetchFavoriteItemsUseCase = fetchFavoriteItemsUseCase + self.fetchFavoriteCategoryItemsUseCase = fetchFavoriteCategoryItemsUseCase + self.fetchClosetUtilizationUseCase = fetchClosetUtilizationUseCase } // MARK: - Methods - func checkCondition() async { + + func loadReport() async { isLoading = true do { canAggregate = try await checkStatisticsConditionUseCase.execute() + if canAggregate { + await fetchAllStatistics() + } } catch { canAggregate = false networkErrorMessage = "네트워크 오류가 발생했습니다. 다시 시도해주세요." @@ -50,12 +84,92 @@ final class WardrobeReportDetailViewModel: ObservableObject { isLoading = false } + private func fetchAllStatistics() async { + // 아이템 통계 & 활용도를 병렬로 가져오기 + async let itemsTask = fetchFavoriteItemsUseCase.execute() + async let utilizationTask = fetchClosetUtilizationUseCase.execute(season: currentSeason) + + do { + let items = try await itemsTask + self.favoriteItems = items.map { + ItemUsageStat(itemName: $0.categoryName, usageCount: $0.clothCount) + } + + // 카테고리별 상세 데이터를 가져오기 (1차 카테고리 기준) + await fetchCategoryDetails() + } catch { + #if DEBUG + print("[WardrobeReport] 아이템 통계 조회 실패: \(error)") + #endif + } + + do { + let utilization = try await utilizationTask + self.wardrobeUsage = WardrobeUsageStat( + totalCount: utilization.utilizedCount + utilization.unutilizedCount, + wornCount: utilization.utilizedCount + ) + self.topUtilizedItemName = utilization.utilizedClothes.first?.name + } catch { + #if DEBUG + print("[WardrobeReport] 활용도 조회 실패: \(error)") + #endif + } + } + + private func fetchCategoryDetails() async { + var categories: [CategoryFavoriteItem] = [] + let colors: [Color] = [.Codive.point1, .Codive.point2, .Codive.point3, .Codive.grayscale5] + + // 1차 카테고리(상의, 바지, 스커트 등) 기준으로 조회 + for parentCategory in CategoryConstants.all { + do { + let details = try await fetchFavoriteCategoryItemsUseCase.execute( + categoryId: Int64(parentCategory.id) + ) + guard !details.isEmpty else { continue } + let segments = details.enumerated().map { index, detail in + DonutSegment( + value: detail.occupancyRate, + color: colors[min(index, colors.count - 1)], + payload: detail.categoryName + ) + } + categories.append( + CategoryFavoriteItem( + parentCategoryId: Int64(parentCategory.id), + categoryName: parentCategory.name, + items: segments + ) + ) + } catch { + #if DEBUG + print("[WardrobeReport] 카테고리 상세 조회 실패 - \(parentCategory.name)(id: \(parentCategory.id)), error: \(error)") + #endif + } + } + self.favoriteCategories = categories + } + // MARK: - Navigation + func navigateBack() { navigationRouter.navigateBack() } func navigateToRecordAdd() { - navigationRouter.navigate(to: .recordAdd) + navigationRouter.navigate(to: .recordAdd()) + } + + func navigateToFavoriteCategory(parentCategoryId: Int64) { + navigationRouter.navigate(to: .wardrobeFavoriteCategory(parentCategoryId: parentCategoryId)) + } + + func navigateToItemStats() { + navigationRouter.navigate(to: .wardrobeItemStats) + } + + func navigateToUsageCheck() { + navigationRouter.navigate(to: .wardrobeUsageCheck) } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/WearingDataViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/WearingDataViewModel.swift new file mode 100644 index 00000000..da895bd6 --- /dev/null +++ b/Codive/Features/Closet/Presentation/ViewModel/WearingDataViewModel.swift @@ -0,0 +1,99 @@ +// +// WearingDataViewModel.swift +// Codive +// +// Created by 황상환 on 5/3/26. +// + +import Foundation + +@MainActor +final class WearingDataViewModel: ObservableObject { + + // MARK: - Published Properties + @Published var isLoading: Bool = false + @Published var stats: WardrobeUsageStat = WardrobeUsageStat(totalCount: 0, wornCount: 0) + @Published var utilizedClothes: [ClothItem] = [] + @Published var unutilizedClothes: [ClothItem] = [] + @Published var selectedBottomSheetTitle: String = "" + @Published var selectedBottomSheetItems: [ClothItem] = [] + /// "입음" 또는 "미착용" — 강조된 segment 추적 + @Published var selectedPayload: String = "입음" + + var seasonLabel: String { Season.current.displayName } + + /// 강조된 segment에 따라 부제 동적 변경 + var subtitleText: String { + let notWorn = max(0, stats.totalCount - stats.wornCount) + if selectedPayload == "미착용" { + return "아직 못 입은 \(seasonLabel) 옷이 \(notWorn)벌 있어요\n그래프를 눌러 확인해보세요!" + } + return "보관 중인 \(seasonLabel) 옷 \(stats.totalCount)벌 중\n\(stats.wornCount)벌을 실제로 입었어요" + } + + /// 도넛 가운데 % 텍스트 — 강조된 segment의 비율 + var centerPercent: Int { + guard stats.totalCount > 0 else { return 0 } + if selectedPayload == "미착용" { + let notWorn = max(0, stats.totalCount - stats.wornCount) + return Int(round(Double(notWorn) / Double(stats.totalCount) * 100)) + } + return stats.usagePercent + } + + func selectSegment(payload: String) { + selectedPayload = payload + let notWorn = max(0, stats.totalCount - stats.wornCount) + if payload == "입음" { + selectedBottomSheetTitle = "\(stats.wornCount)벌 착용" + selectedBottomSheetItems = utilizedClothes + } else if payload == "미착용" { + selectedBottomSheetTitle = "\(notWorn)벌 미착용" + selectedBottomSheetItems = unutilizedClothes + } + } + + // MARK: - Private Properties + private let navigationRouter: NavigationRouter + private let fetchClosetUtilizationUseCase: FetchClosetUtilizationUseCase + + private var currentSeason: String { Season.current.rawValue } + + // MARK: - Initializer + init( + navigationRouter: NavigationRouter, + fetchClosetUtilizationUseCase: FetchClosetUtilizationUseCase + ) { + self.navigationRouter = navigationRouter + self.fetchClosetUtilizationUseCase = fetchClosetUtilizationUseCase + } + + // MARK: - Methods + func loadData() async { + isLoading = true + do { + let result = try await fetchClosetUtilizationUseCase.execute(season: currentSeason) + self.stats = WardrobeUsageStat( + totalCount: result.utilizedCount + result.unutilizedCount, + wornCount: result.utilizedCount + ) + self.utilizedClothes = result.utilizedClothes.map { + ClothItem(imageUrl: $0.imageUrl, brand: $0.brand, name: $0.name) + } + self.unutilizedClothes = result.unutilizedClothes.map { + ClothItem(imageUrl: $0.imageUrl, brand: $0.brand, name: $0.name) + } + // 첫 진입 시 입음 자동 강조 + selectSegment(payload: "입음") + } catch { + #if DEBUG + print("[WearingData] 데이터 로드 실패: \(error)") + #endif + } + isLoading = false + } + + func navigateBack() { + navigationRouter.navigateBack() + } +} diff --git a/Codive/Features/Comment/Presentation/View/CommentRow.swift b/Codive/Features/Comment/Presentation/View/CommentRow.swift index 9bf3acb3..d0daa6c8 100644 --- a/Codive/Features/Comment/Presentation/View/CommentRow.swift +++ b/Codive/Features/Comment/Presentation/View/CommentRow.swift @@ -17,7 +17,7 @@ struct CommentRow: View { let onFetchRepliesTap: (Int) -> Void let onFetchAllRepliesTap: (Int) -> Void let onProfileImageTap: (String, Bool) -> Void - let onMoreTap: (Comment) -> Void + let onMoreTap: (Comment, CGRect) -> Void @State private var isExpanded: Bool = false @@ -97,13 +97,18 @@ struct CommentRow: View { } .frame(maxWidth: .infinity, alignment: .leading) - Button( - action: { onMoreTap(comment) }, - label: { - Image("more") - .font(.system(size: 12)) - } - ) + GeometryReader { proxy in + Button( + action: { + onMoreTap(comment, proxy.frame(in: .named("commentList"))) + }, + label: { + Image("more") + .font(.system(size: 12)) + } + ) + } + .frame(width: 20, height: 20) } .padding(.leading, isReply ? 40 : 0) @@ -208,7 +213,7 @@ struct CommentRow_Previews: PreviewProvider { onFetchRepliesTap: { _ in }, onFetchAllRepliesTap: { _ in }, onProfileImageTap: { _, _ in }, - onMoreTap: { _ in } + onMoreTap: { _, _ in } ) .previewDisplayName("댓글 아이템") } diff --git a/Codive/Features/Comment/Presentation/View/CommentView.swift b/Codive/Features/Comment/Presentation/View/CommentView.swift index ad6268f6..506a6222 100644 --- a/Codive/Features/Comment/Presentation/View/CommentView.swift +++ b/Codive/Features/Comment/Presentation/View/CommentView.swift @@ -49,7 +49,9 @@ struct CommentView: View { onFetchRepliesTap: { viewModel.fetchReplies(for: $0) }, onFetchAllRepliesTap: { viewModel.fetchAllReplies(for: $0) }, onProfileImageTap: { viewModel.navigateToProfile(userId: $0, isMine: $1) }, - onMoreTap: { viewModel.toggleMenu(commentId: $0.id) } + onMoreTap: { comment, frame in + viewModel.toggleMenu(commentId: comment.id, buttonFrame: frame) + } ) } if viewModel.isLoading { @@ -77,6 +79,7 @@ struct CommentView: View { } viewModel.fetchFirstPage() } + .coordinateSpace(name: "commentList") // MARK: - Comment Input Area VStack(spacing: 0) { @@ -156,7 +159,7 @@ struct CommentView: View { // MARK: - 더보기 메뉴 오버레이 if let menuCommentId = viewModel.expandedMenuCommentId, let menuComment = viewModel.findComment(by: menuCommentId) { - ZStack(alignment: .topTrailing) { + ZStack(alignment: .topLeading) { Color.black .opacity(0.001) .contentShape(Rectangle()) @@ -172,6 +175,7 @@ struct CommentView: View { ) // swiftlint:disable trailing_closure + let menuY = viewModel.menuButtonFrame.midY + 40 if menuComment.isMine { CustomOverflowMenu( menuType: .commentDelete, @@ -182,8 +186,9 @@ struct CommentView: View { showButton: false, onClose: { viewModel.dismissMenu() } ) + .frame(maxWidth: .infinity, alignment: .trailing) .padding(.trailing, 20) - .padding(.top, 80) + .offset(y: menuY) } else { CustomOverflowMenu( menuType: .report, @@ -195,8 +200,9 @@ struct CommentView: View { showButton: false, onClose: { viewModel.dismissMenu() } ) + .frame(maxWidth: .infinity, alignment: .trailing) .padding(.trailing, 20) - .padding(.top, 80) + .offset(y: menuY) } // swiftlint:enable trailing_closure } diff --git a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift index d299174f..1868f574 100644 --- a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift +++ b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift @@ -32,6 +32,7 @@ final class CommentViewModel: ObservableObject { // 더보기 메뉴 관련 상태 @Published var expandedMenuCommentId: Int? + @Published var menuButtonFrame: CGRect = .zero @Published var showDeleteAlert: Bool = false @Published var pendingDeleteCommentId: Int? @Published var showBlockAlert: Bool = false @@ -226,10 +227,11 @@ final class CommentViewModel: ObservableObject { // MARK: - Menu Methods - func toggleMenu(commentId: Int) { + func toggleMenu(commentId: Int, buttonFrame: CGRect = .zero) { if expandedMenuCommentId == commentId { expandedMenuCommentId = nil } else { + menuButtonFrame = buttonFrame expandedMenuCommentId = commentId } } diff --git a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift index 0195c6eb..ac3def96 100644 --- a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift @@ -12,6 +12,7 @@ import UIKit struct RecordCreateRequest { let content: String? + let historyDate: String let situationId: Int64 let styleIds: [Int64] let hashtags: [String] @@ -63,6 +64,7 @@ final class DefaultRecordDataSource: RecordDataSource { let apiRequest = HistoryCreateAPIRequest( content: request.content, + historyDate: request.historyDate, situationId: request.situationId, styleIds: request.styleIds, hashtags: request.hashtags, @@ -84,6 +86,7 @@ final class DefaultRecordDataSource: RecordDataSource { let apiRequest = HistoryCreateAPIRequest( content: request.content, + historyDate: request.historyDate, situationId: request.situationId, styleIds: request.styleIds, hashtags: request.hashtags, diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 6019e13a..c0faa15e 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -17,6 +17,7 @@ protocol HistoryAPIServiceProtocol { func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItemDTO] + func checkTodayHistoryExistence() async throws -> Bool func deleteHistory(historyId: Int64) async throws func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String, contentType: String) async throws @@ -64,6 +65,7 @@ struct ClothTagDTO { struct HistoryCreateAPIRequest { let content: String? + let historyDate: String let situationId: Int64 let styleIds: [Int64] let hashtags: [String] @@ -111,6 +113,7 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { let requestBody = Components.Schemas.HistoryCreateRequest( content: request.content, + historyDate: request.historyDate, situationId: request.situationId, styleIds: request.styleIds, hashtags: hashtagContainers, @@ -302,6 +305,22 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { } } + // MARK: - Check Today History Existence + + func checkTodayHistoryExistence() async throws -> Bool { + let input = Operations.History_checkTodayHistoryExistence.Input() + let response = try await client.History_checkTodayHistoryExistence(input) + + switch response { + case .ok(let okResponse): + let decoded = try okResponse.body.json + return decoded.result?.exists ?? false + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } + // MARK: - Helper Methods private func extractErrorDetail(from payload: UndocumentedPayload) async -> String { diff --git a/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift index 90002973..c82ccfff 100644 --- a/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift @@ -32,6 +32,10 @@ final class HistoryRepositoryImpl: HistoryRepository { } } + func checkTodayHistoryExists() async throws -> Bool { + try await historyAPIService.checkTodayHistoryExistence() + } + func deleteHistory(historyId: Int64) async throws { try await historyAPIService.deleteHistory(historyId: historyId) } diff --git a/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift index d382eacb..e61a8327 100644 --- a/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift @@ -9,5 +9,6 @@ import Foundation protocol HistoryRepository { func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] + func checkTodayHistoryExists() async throws -> Bool func deleteHistory(historyId: Int64) async throws } diff --git a/Codive/Features/Feed/Domain/UseCases/CheckTodayRecordUseCase.swift b/Codive/Features/Feed/Domain/UseCases/CheckTodayRecordUseCase.swift index 995d62d1..c658a415 100644 --- a/Codive/Features/Feed/Domain/UseCases/CheckTodayRecordUseCase.swift +++ b/Codive/Features/Feed/Domain/UseCases/CheckTodayRecordUseCase.swift @@ -7,39 +7,18 @@ import Foundation final class CheckTodayRecordUseCase { private let historyRepository: HistoryRepository - private let memberIdProvider: () -> Int? - init( - historyRepository: HistoryRepository, - memberIdProvider: @escaping () -> Int? - ) { + init(historyRepository: HistoryRepository) { self.historyRepository = historyRepository - self.memberIdProvider = memberIdProvider } /// 오늘 날짜에 이미 기록이 존재하는지 확인 func execute() async -> Bool { - guard let userId = memberIdProvider() else { return false } - - let now = Date() - let calendar = Calendar.current - let year = Int32(calendar.component(.year, from: now)) - let month = Int32(calendar.component(.month, from: now)) - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - let todayString = formatter.string(from: now) - do { - let histories = try await historyRepository.fetchMonthlyHistory( - memberId: Int64(userId), - year: year, - month: month - ) - return histories.contains { $0.historyDate == todayString } + return try await historyRepository.checkTodayHistoryExists() } catch { #if DEBUG - print("[CheckTodayRecord] 월간 기록 조회 실패: \(error)") + print("[CheckTodayRecord] 오늘 기록 확인 실패: \(error)") #endif return false } diff --git a/Codive/Features/Feed/Presentation/Add/View/HashtagTextEditor.swift b/Codive/Features/Feed/Presentation/Add/View/HashtagTextEditor.swift index ebaefa72..31ab2b16 100644 --- a/Codive/Features/Feed/Presentation/Add/View/HashtagTextEditor.swift +++ b/Codive/Features/Feed/Presentation/Add/View/HashtagTextEditor.swift @@ -106,6 +106,14 @@ extension HashtagTextEditor { } } + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if isShowingPlaceholder { return true } + let currentText = textView.text ?? "" + guard let stringRange = Range(range, in: currentText) else { return false } + let updatedText = currentText.replacingCharacters(in: stringRange, with: text) + return updatedText.count <= 120 + } + func textViewDidChange(_ textView: UITextView) { if isShowingPlaceholder { return diff --git a/Codive/Features/Feed/Presentation/Add/View/PhotoTagView.swift b/Codive/Features/Feed/Presentation/Add/View/PhotoTagView.swift index 8bee747a..332a71a6 100644 --- a/Codive/Features/Feed/Presentation/Add/View/PhotoTagView.swift +++ b/Codive/Features/Feed/Presentation/Add/View/PhotoTagView.swift @@ -61,7 +61,7 @@ struct PhotoTagView: View { .ignoresSafeArea(.all, edges: .bottom) } .background(Color.white) - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() } .ignoresSafeArea(.all, edges: .bottom) diff --git a/Codive/Features/Feed/Presentation/Add/View/RecordDetailView.swift b/Codive/Features/Feed/Presentation/Add/View/RecordDetailView.swift index 31909655..99202565 100644 --- a/Codive/Features/Feed/Presentation/Add/View/RecordDetailView.swift +++ b/Codive/Features/Feed/Presentation/Add/View/RecordDetailView.swift @@ -57,7 +57,7 @@ struct RecordDetailView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .background(Color.white) .onTapGesture { UIApplication.shared.hideKeyboard() @@ -189,6 +189,17 @@ private extension RecordDetailView { RoundedRectangle(cornerRadius: 10) .stroke(Color.Codive.grayscale5, lineWidth: 1) ) + + HStack { + Spacer() + Text("\(viewModel.captionText.count)/120") + .font(.codive_body2_regular) + .foregroundStyle( + viewModel.captionText.count >= 120 + ? Color.Codive.point1 + : Color.Codive.grayscale4 + ) + } } .padding(.horizontal, 20) .padding(.bottom, 40) diff --git a/Codive/Features/Feed/Presentation/Add/View/TaggableImageView.swift b/Codive/Features/Feed/Presentation/Add/View/TaggableImageView.swift index 17b45e03..4429fdc4 100644 --- a/Codive/Features/Feed/Presentation/Add/View/TaggableImageView.swift +++ b/Codive/Features/Feed/Presentation/Add/View/TaggableImageView.swift @@ -133,8 +133,10 @@ private struct DraggableTag: View { .gesture( isDraggable ? DragGesture(coordinateSpace: .named("imageZStack")) .onChanged { value in - let newX = value.location.x / imageSize.width - let newY = value.location.y / imageSize.height + let tagHalfW: CGFloat = 70 / max(imageSize.width, 1) + let tagHalfH: CGFloat = 40 / max(imageSize.height, 1) + let newX = min(max(value.location.x / imageSize.width, tagHalfW), 1 - tagHalfW) + let newY = min(max(value.location.y / imageSize.height, tagHalfH), 1 - tagHalfH) onDrag(newX, newY) } : nil diff --git a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift index cb8b34aa..6650d31f 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift @@ -49,6 +49,7 @@ final class RecordDetailViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let recordDataSource: RecordDataSource + private let selectedDate: Date? // MARK: - Options let styleOptions = [ @@ -90,12 +91,14 @@ final class RecordDetailViewModel: ObservableObject { init( selectedPhotos: [SelectedPhoto], navigationRouter: NavigationRouter, - recordDataSource: RecordDataSource = DefaultRecordDataSource() + recordDataSource: RecordDataSource = DefaultRecordDataSource(), + selectedDate: Date? = nil ) { self.selectedPhotos = selectedPhotos self.navigationRouter = navigationRouter self.recordDataSource = recordDataSource self.isEditMode = false + self.selectedDate = selectedDate // 태그 업데이트 구독 setupPhotoTagSubscription() @@ -112,6 +115,7 @@ final class RecordDetailViewModel: ObservableObject { self.isEditMode = true self.editingFeedId = feed.id self.editingFeedData = feed + self.selectedDate = nil // 이미지 데이터 로드 self.selectedPhotos = [] @@ -158,9 +162,7 @@ final class RecordDetailViewModel: ObservableObject { } } - DispatchQueue.main.async { - self.selectedPhotos = loadedPhotos - } + self.selectedPhotos = loadedPhotos } /// URL에서 이미지를 다운로드 @@ -188,11 +190,11 @@ final class RecordDetailViewModel: ObservableObject { Task { do { let request = try buildRecordRequest() - try await saveRecord(request) + let feedId = try await saveRecord(request) isLoading = false NotificationCenter.default.post(name: .feedDidCreate, object: nil) let message = isEditMode ? "기록이 수정되었습니다" : "기록이 저장되었습니다" - navigationRouter.showSuccessAndNavigate(message: message, to: .feed) + navigationRouter.showSuccessAndNavigate(message: message, to: .feed, destination: .feedDetail(feedId: feedId)) } catch { handleRecordError(error) } @@ -233,8 +235,11 @@ final class RecordDetailViewModel: ObservableObject { } // 5. 요청 생성 + let historyDate = (selectedDate ?? Date()).toDateString() + return RecordCreateRequest( content: captionText.isEmpty ? nil : captionText, + historyDate: historyDate, situationId: situationId, styleIds: styleIds, hashtags: hashtags, @@ -242,13 +247,15 @@ final class RecordDetailViewModel: ObservableObject { ) } - private func saveRecord(_ request: RecordCreateRequest) async throws { + @discardableResult + private func saveRecord(_ request: RecordCreateRequest) async throws -> Int { if isEditMode, let feedId = editingFeedId { - // API는 Int64를 요구하므로 변환 let historyId = Int64(feedId) try await recordDataSource.updateRecord(historyId: historyId, request: request) + return feedId } else { - _ = try await recordDataSource.createRecord(request: request) + let historyId = try await recordDataSource.createRecord(request: request) + return Int(historyId) } } diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift index b3b81c15..d49ca0a3 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift @@ -170,7 +170,7 @@ struct FeedDetailView: View { } } .background(Color.white) - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .onAppear { Task { diff --git a/Codive/Features/Home/Presentation/Component/CodiClothView.swift b/Codive/Features/Home/Presentation/Component/CodiClothView.swift index edf9c8a4..30c078cd 100644 --- a/Codive/Features/Home/Presentation/Component/CodiClothView.swift +++ b/Codive/Features/Home/Presentation/Component/CodiClothView.swift @@ -161,6 +161,10 @@ struct CodiClothCarouselView: View { ) } .onChange(of: currentIndex) { newValue in + if isEmptyState { + currentIndex = 1 + return + } withAnimation(.spring()) { proxy.scrollTo(newValue, anchor: .center) } diff --git a/Codive/Features/Home/Presentation/Component/DraggableImageView.swift b/Codive/Features/Home/Presentation/Component/DraggableImageView.swift index f380f6ee..bc384bb0 100644 --- a/Codive/Features/Home/Presentation/Component/DraggableImageView.swift +++ b/Codive/Features/Home/Presentation/Component/DraggableImageView.swift @@ -10,17 +10,25 @@ import Kingfisher struct DraggableImageView: View { @Binding var items: [T] + let selectedImageID: Int64? let onActivate: (Int64) -> Void + let onDeselect: () -> Void var body: some View { ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { onDeselect() } + ForEach($items) { $item in ZoomRotateDragView( id: item.id, position: $item.position, scale: $item.scale, rotation: $item.rotation, + isSelected: item.id == selectedImageID, onActivate: { onActivate(item.id) }, + onTap: { onActivate(item.id) }, content: { KFImage(URL(string: item.imageUrl)) .placeholder { diff --git a/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift b/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift index 9fa6a599..91882190 100644 --- a/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift +++ b/Codive/Features/Home/Presentation/Component/ZoomRotateDragView.swift @@ -12,66 +12,145 @@ struct ZoomRotateDragView: View { @Binding var position: CGPoint @Binding var scale: CGFloat @Binding var rotation: Double - + let isSelected: Bool + let onActivate: () -> Void + let onTap: () -> Void let content: Content @GestureState private var gestureOffset: CGSize = .zero - @GestureState private var gestureScale: CGFloat = 1.0 - @GestureState private var gestureRotation: Angle = .zero - - // MARK: - 제약 조건 설정 - private let minScale: CGFloat = 0.4 + @State private var isHandleDragging: Bool = false + + // 핸들 드래그 시 초기값 + @State private var handleStartPos: CGPoint = .zero + @State private var handleInitialScale: CGFloat = 1.0 + @State private var handleInitialRotation: Double = 0 + + // MARK: - 제약 조건 + private let minScale: CGFloat = 0.3 private let maxScale: CGFloat = 4.0 + private let itemSize: CGFloat = 180 + private let handleSize: CGFloat = 28 init( id: Int64, position: Binding, scale: Binding, rotation: Binding, + isSelected: Bool, onActivate: @escaping () -> Void, + onTap: @escaping () -> Void, @ViewBuilder content: () -> Content ) { self.id = id self._position = position self._scale = scale self._rotation = rotation + self.isSelected = isSelected self.onActivate = onActivate + self.onTap = onTap self.content = content() } var body: some View { content - .scaleEffect(clampedScale(scale * gestureScale)) - .rotationEffect(Angle(degrees: rotation) + gestureRotation) - .offset(x: position.x + gestureOffset.width, y: position.y + gestureOffset.height) - .highPriorityGesture( - SimultaneousGesture( - DragGesture() - .updating($gestureOffset) { v, s, _ in - s = v.translation - onActivate() - } - .onEnded { v in - position.x += v.translation.width - position.y += v.translation.height - }, - MagnificationGesture() - .updating($gestureScale) { v, s, _ in s = v } - .onEnded { v in - let newScale = scale * v - scale = min(max(newScale, minScale), maxScale) - } - ) - ) - .simultaneousGesture( - RotationGesture() - .updating($gestureRotation) { v, s, _ in s = v } - .onEnded { v in rotation += v.degrees } - ) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 12) + .stroke( + style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]) + ) + .foregroundStyle(Color.gray.opacity(0.5)) + .frame(width: itemSize, height: itemSize) + } + } + .scaleEffect(scale) + .rotationEffect(Angle(degrees: rotation)) + .overlay { + // 핸들 (scale/rotation 밖 — 위치를 직접 계산) + if isSelected { + Circle() + .fill(Color.white) + .overlay( + Circle() + .stroke(Color.gray.opacity(0.6), lineWidth: 2) + ) + .shadow(color: .black.opacity(0.15), radius: 2, y: 1) + .frame(width: handleSize, height: handleSize) + .offset(cornerOffset) + .highPriorityGesture(handleDragGesture) + .allowsHitTesting(true) + } + } + .offset( + x: position.x + gestureOffset.width, + y: position.y + gestureOffset.height + ) + .onTapGesture { onTap() } + .gesture(isHandleDragging ? nil : dragGesture) + } + + // MARK: - 핸들 위치 (스크린 좌표 기준 우하단 모서리) + private var cornerOffset: CGSize { + let half = (itemSize / 2) * scale + let rad = CGFloat(rotation * .pi / 180) + return CGSize( + width: half * cos(rad) - half * sin(rad), + height: half * sin(rad) + half * cos(rad) + ) + } + + // MARK: - 본체 드래그 (위치 이동) + private var dragGesture: some Gesture { + DragGesture() + .updating($gestureOffset) { value, state, _ in + state = value.translation + onActivate() + } + .onEnded { value in + position.x += value.translation.width + position.y += value.translation.height + } } - - private func clampedScale(_ current: CGFloat) -> CGFloat { - return min(max(current, minScale * 0.8), maxScale * 1.2) + + // MARK: - 핸들 드래그 (스케일 + 회전) + private var handleDragGesture: some Gesture { + DragGesture() + .onChanged { value in + if !isHandleDragging { + isHandleDragging = true + // 드래그 시작: 핸들의 현재 스크린 위치 저장 + let half = (itemSize / 2) * scale + let rad = CGFloat(rotation * .pi / 180) + handleStartPos = CGPoint( + x: half * cos(rad) - half * sin(rad), + y: half * sin(rad) + half * cos(rad) + ) + handleInitialScale = scale + handleInitialRotation = rotation + } + + // 현재 핸들 위치 (스크린 좌표, 아이템 중심 기준) + let currentX = handleStartPos.x + value.translation.width + let currentY = handleStartPos.y + value.translation.height + + // 초기 거리 & 현재 거리 → 스케일 + let initDist = sqrt(handleStartPos.x * handleStartPos.x + handleStartPos.y * handleStartPos.y) + let newDist = sqrt(currentX * currentX + currentY * currentY) + + guard initDist > 0 else { return } + + let newScale = handleInitialScale * (newDist / initDist) + scale = min(max(newScale, minScale), maxScale) + + // 초기 각도 & 현재 각도 → 회전 + let initAngle = atan2(Double(handleStartPos.y), Double(handleStartPos.x)) + let newAngle = atan2(Double(currentY), Double(currentX)) + let angleDiff = (newAngle - initAngle) * 180 / .pi + rotation = handleInitialRotation + angleDiff + } + .onEnded { _ in + isHandleDragging = false + } } } diff --git a/Codive/Features/Home/Presentation/View/CodiBoardView.swift b/Codive/Features/Home/Presentation/View/CodiBoardView.swift index dee1d805..65f32c4a 100644 --- a/Codive/Features/Home/Presentation/View/CodiBoardView.swift +++ b/Codive/Features/Home/Presentation/View/CodiBoardView.swift @@ -32,7 +32,7 @@ struct CodiBoardView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .onChange(of: viewModel.isConfirmed) { confirmed in @@ -85,10 +85,17 @@ private extension CodiBoardView { ZStack { boardBackground(size: size) - DraggableImageView(items: $viewModel.images) { id in - viewModel.selectImage(id: Int(id)) - viewModel.bringImageToFront(id: Int(id)) - } + DraggableImageView( + items: $viewModel.images, + selectedImageID: viewModel.selectedImageID.map { Int64($0) }, + onActivate: { id in + viewModel.selectImage(id: Int(id)) + viewModel.bringImageToFront(id: Int(id)) + }, + onDeselect: { + viewModel.selectImage(id: nil) + } + ) } .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: 15)) diff --git a/Codive/Features/Home/Presentation/View/EditCategoryView.swift b/Codive/Features/Home/Presentation/View/EditCategoryView.swift index 428b162b..60ce16dd 100644 --- a/Codive/Features/Home/Presentation/View/EditCategoryView.swift +++ b/Codive/Features/Home/Presentation/View/EditCategoryView.swift @@ -32,7 +32,7 @@ struct EditCategoryView: View { bottomActionButtons } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .background(Color.white) .enableSwipeBack() .alert(TextLiteral.Home.changeAlertTitle, isPresented: $viewModel.showExitAlert) { diff --git a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift index e758961f..fda3d55f 100644 --- a/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeHasCodiView.swift @@ -12,6 +12,7 @@ struct HomeHasCodiView: View { // MARK: - Properties @ObservedObject var viewModel: HomeViewModel let width: CGFloat + let onBannerTapped: () -> Void // MARK: - Body var body: some View { @@ -44,12 +45,12 @@ struct HomeHasCodiView: View { bottomBanner } .contentShape(Rectangle()) - .onTapGesture { - // 2. 메뉴가 열려있을 때 바탕을 누르면 닫기 + .simultaneousGesture(TapGesture().onEnded { + // 메뉴가 열려있을 때 바탕을 누르면 닫기 if viewModel.isOverflowMenuExpanded { viewModel.closeOverflowMenu() } - } + }) overflowMenu } @@ -69,7 +70,7 @@ private extension HomeHasCodiView { /// 하단 배너 var bottomBanner: some View { - CustomBanner(text: TextLiteral.Home.bannerTitle) {} + CustomBanner(text: TextLiteral.Home.bannerTitle, onTap: onBannerTapped) .padding() } diff --git a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift index f543a865..4333519c 100644 --- a/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift +++ b/Codive/Features/Home/Presentation/View/HomeNoCodiView.swift @@ -50,8 +50,7 @@ private extension HomeNoCodiView { viewModel.handleEditCategory() } CodiButton(iconName: "shuffle", title: TextLiteral.Home.random) { - // 랜덤 로직 - viewModel.selectEditCodi() + viewModel.randomizeCodi() } } .padding(.horizontal, 20) diff --git a/Codive/Features/Home/Presentation/View/HomeView.swift b/Codive/Features/Home/Presentation/View/HomeView.swift index 3dc79639..00b2a34a 100644 --- a/Codive/Features/Home/Presentation/View/HomeView.swift +++ b/Codive/Features/Home/Presentation/View/HomeView.swift @@ -13,11 +13,13 @@ struct HomeView: View { @ObservedObject var viewModel: HomeViewModel @ObservedObject private var navigationRouter: NavigationRouter @State private var scrollViewID = UUID() - - init(homeDIContainer: HomeDIContainer, viewModel: HomeViewModel) { + let onBannerTapped: () -> Void + + init(homeDIContainer: HomeDIContainer, viewModel: HomeViewModel, onBannerTapped: @escaping () -> Void) { self.homeDIContainer = homeDIContainer self.viewModel = viewModel self._navigationRouter = ObservedObject(wrappedValue: homeDIContainer.navigationRouter) + self.onBannerTapped = onBannerTapped } var body: some View { @@ -51,7 +53,8 @@ struct HomeView: View { if viewModel.hasCodi { HomeHasCodiView( viewModel: viewModel, - width: outerGeometry.size.width + width: outerGeometry.size.width, + onBannerTapped: onBannerTapped ) .transition(.opacity) } else { diff --git a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift index 6c6892e4..2e3661b0 100644 --- a/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift +++ b/Codive/Features/Home/Presentation/ViewModel/CodiBoardViewModel.swift @@ -59,12 +59,6 @@ final class CodiBoardViewModel: ObservableObject { // MARK: - Navigation func handleBackTap() { - if let homeVM = homeViewModel { - if homeVM.todayCodiPreview != nil { - homeVM.hasCodi = true - } - homeVM.isEditingExistingCodi = false - } navigationRouter.navigateBack() } @@ -80,7 +74,12 @@ final class CodiBoardViewModel: ObservableObject { Rectangle() .fill(Color.Codive.grayscale7) - DraggableImageView(items: .constant(images)) { _ in } + DraggableImageView( + items: .constant(images), + selectedImageID: nil, + onActivate: { _ in }, + onDeselect: { } + ) } .frame(width: actualSize, height: actualSize) .clipped() diff --git a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift index 35f5bdb7..6cb1bc2e 100644 --- a/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift +++ b/Codive/Features/Home/Presentation/ViewModel/HomeViewModel+.swift @@ -49,6 +49,25 @@ extension HomeViewModel { } } + /// 랜덤 코디: 각 카테고리별로 옷을 랜덤 선택 + func randomizeCodi() { + Task { + if clothItemsByCategory.isEmpty { + await loadRecommendCategoryClothList(seasons: self.currentSeasons) + } + + await MainActor.run { + var randomIndices: [Int: Int] = [:] + for category in activeCategories { + if let items = clothItemsByCategory[category.id], !items.isEmpty { + randomIndices[category.id] = Int.random(in: 0.. [CoordinateDetailResponseDTO] /// 옷 리스트 조회 - func fetchClothItems(category: String?) async throws -> [ProductItem] - + func fetchClothItems(category: String?, searchText: String?) async throws -> [ProductItem] + /// 룩북 생성 func createLookBook(request: CreateLookBookAPIRequestDTO) async throws -> CreateLookBookResponseDTO @@ -131,23 +131,42 @@ final class LookBookDataSource: LookBookDataSourceProtocol { } /// 옷 리스트 조회 - func fetchClothItems(category: String?) async throws -> [ProductItem] { - // 전체 옷 목록 조회 (페이지네이션 없이 전체) + func fetchClothItems(category: String?, searchText: String?) async throws -> [ProductItem] { + let categoryId: Int64? = { + guard let category, category != "전체", + let found = CategoryConstants.category(byName: category) else { + return nil + } + return Int64(found.id) + }() + let result = try await apiService.fetchClothes( lastClothId: nil, size: 100, - categoryId: nil, + categoryId: categoryId, seasons: [] ) - - return result.clothes.map { item in + + var items = result.clothes.map { item in ProductItem( id: Int(item.clothId), imageUrl: item.imageUrl, + isTodayCloth: item.isTodayCoordinateCloth, brand: item.brand, - name: item.name + name: item.name, + mainCategory: item.parentCategory, + subCategory: item.category ) } + + if let searchText, !searchText.isEmpty { + items = items.filter { + ($0.name ?? "").localizedCaseInsensitiveContains(searchText) || + ($0.brand ?? "").localizedCaseInsensitiveContains(searchText) + } + } + + return items } /// 룩북 생성 diff --git a/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift b/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift index c55593fc..dd6b2855 100644 --- a/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift +++ b/Codive/Features/LookBook/Data/Repositories/LookBookRepositoryImpl.swift @@ -91,8 +91,8 @@ final class LookBookRepositoryImpl: LookBookRepository { } /// 옷 리스트 조회 - func fetchClothItems(category: String?) async throws -> [ProductItem] { - return try await datasource.fetchClothItems(category: category) + func fetchClothItems(category: String?, searchText: String?) async throws -> [ProductItem] { + return try await datasource.fetchClothItems(category: category, searchText: searchText) } /// 룩북 생성 diff --git a/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift b/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift index c92a0553..49d08bc0 100644 --- a/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift +++ b/Codive/Features/LookBook/Domain/Entities/LookBookEntity.swift @@ -83,6 +83,8 @@ struct CodiItem: Identifiable { let brand: String let name: String let clothId: Int64 + var category: String = "" + var parentCategory: String = "" } struct SelectedCodi: Hashable { diff --git a/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift b/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift index afeba038..fae52355 100644 --- a/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift +++ b/Codive/Features/LookBook/Domain/Protocols/LookBookRepository.swift @@ -40,7 +40,7 @@ protocol LookBookRepository { ) async throws -> [CoordinateDetailEntity] /// 옷 리스트 조회 - func fetchClothItems(category: String?) async throws -> [ProductItem] + func fetchClothItems(category: String?, searchText: String?) async throws -> [ProductItem] /// 룩북 생성 func createLookBook(request: CreateLookBookAPIRequestDTO) async throws -> CreateLookBookResponseDTO diff --git a/Codive/Features/LookBook/Domain/UseCases/ProductUseCase.swift b/Codive/Features/LookBook/Domain/UseCases/ProductUseCase.swift index f4194dce..62f22f5f 100644 --- a/Codive/Features/LookBook/Domain/UseCases/ProductUseCase.swift +++ b/Codive/Features/LookBook/Domain/UseCases/ProductUseCase.swift @@ -18,8 +18,8 @@ final class ProductUseCase { } // MARK: - Methods - func execute(category: String? = nil) async throws -> [ProductItem] { - return try await repository.fetchClothItems(category: category) + func execute(category: String? = nil, searchText: String? = nil) async throws -> [ProductItem] { + return try await repository.fetchClothItems(category: category, searchText: searchText) } func execute(jpgData: Data) async throws -> String { diff --git a/Codive/Features/LookBook/Presentation/Component/LookBookDialog.swift b/Codive/Features/LookBook/Presentation/Component/LookBookDialog.swift index b6135652..5463289c 100644 --- a/Codive/Features/LookBook/Presentation/Component/LookBookDialog.swift +++ b/Codive/Features/LookBook/Presentation/Component/LookBookDialog.swift @@ -30,7 +30,10 @@ struct LookBookDialog: View { .padding(.top, 30) VStack(alignment: .center, spacing: 5) { - TextField(hintText, text: $inputText) + TextField(hintText, text: Binding( + get: { inputText }, + set: { inputText = String($0.prefix(10)) } + )) .multilineTextAlignment(.center) .font(Font.codive_body2_medium) .foregroundStyle(.black) diff --git a/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift b/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift index 6d1b1f47..676e0019 100644 --- a/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift +++ b/Codive/Features/LookBook/Presentation/View/AddBeforeCodiView.swift @@ -27,7 +27,7 @@ struct AddBeforeCodiView: View { contentScrollView } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .onAppear { diff --git a/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift b/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift index 8c1517e7..69313171 100644 --- a/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift +++ b/Codive/Features/LookBook/Presentation/View/AddCodiDetailView.swift @@ -18,10 +18,16 @@ struct AddCodiDetailView: View { .fill(Color(UIColor.systemGray6)) DraggableImageView( - items: $viewModel.images - ) { id in - viewModel.bringImageToFront(id: id) - } + items: $viewModel.images, + selectedImageID: viewModel.selectedImageID, + onActivate: { id in + viewModel.selectImage(id: id) + viewModel.bringImageToFront(id: id) + }, + onDeselect: { + viewModel.selectImage(id: nil) + } + ) } .frame(width: viewModel.boardSize, height: viewModel.boardSize) .clipped() @@ -45,6 +51,7 @@ struct AddCodiDetailView: View { isEnabled: !viewModel.selectedProductIds.isEmpty ) { Task { + viewModel.selectImage(id: nil) try? await Task.sleep(nanoseconds: 200_000_000) await viewModel.captureBoard(view: codiBoardView) await viewModel.handleComplete() @@ -84,7 +91,7 @@ struct AddCodiDetailView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white.ignoresSafeArea()) } diff --git a/Codive/Features/LookBook/Presentation/View/AddCodiView.swift b/Codive/Features/LookBook/Presentation/View/AddCodiView.swift index 7074c099..7804baae 100644 --- a/Codive/Features/LookBook/Presentation/View/AddCodiView.swift +++ b/Codive/Features/LookBook/Presentation/View/AddCodiView.swift @@ -32,7 +32,7 @@ struct AddCodiView: View { successOverlay } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingSuccessView) @@ -67,16 +67,19 @@ private extension AddCodiView { } var codiPreviewArea: some View { - ZStack { - if let capturedImage = viewModel.capturedImage { - selectedImagePreview(image: capturedImage) - } else if let imageURL = viewModel.selectedImageURL, !imageURL.isEmpty { - selectedImagePreview(url: imageURL) - } else { - emptyUploadPlaceholder + GeometryReader { geometry in + ZStack { + if let capturedImage = viewModel.capturedImage { + selectedImagePreview(image: capturedImage) + } else if let imageURL = viewModel.selectedImageURL, !imageURL.isEmpty { + selectedImagePreview(url: imageURL) + } else { + emptyUploadPlaceholder + } } + .frame(width: geometry.size.width, height: geometry.size.width) } - .frame(height: 335) + .aspectRatio(1, contentMode: .fit) } var inputSection: some View { @@ -118,7 +121,6 @@ private extension AddCodiView { EditCodiOverlayView() .clipShape(RoundedRectangle(cornerRadius: 12)) } - .frame(height: 335) .clipShape(RoundedRectangle(cornerRadius: 12)) } @@ -142,7 +144,6 @@ private extension AddCodiView { } } } - .frame(height: 335) .clipShape(RoundedRectangle(cornerRadius: 12)) } @@ -156,6 +157,7 @@ private extension AddCodiView { ) { viewModel.handleCodiUploadTap() } + .fixedSize() } } } diff --git a/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift b/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift index 2b749165..0d777e43 100644 --- a/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift +++ b/Codive/Features/LookBook/Presentation/View/CodiDetailView.swift @@ -42,7 +42,7 @@ struct CodiDetailView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .onAppear { diff --git a/Codive/Features/LookBook/Presentation/View/EditCodiView.swift b/Codive/Features/LookBook/Presentation/View/EditCodiView.swift index 55686e79..32b4ed6e 100644 --- a/Codive/Features/LookBook/Presentation/View/EditCodiView.swift +++ b/Codive/Features/LookBook/Presentation/View/EditCodiView.swift @@ -35,7 +35,7 @@ struct EditCodiView: View { completeButton } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) } diff --git a/Codive/Features/LookBook/Presentation/View/LookBookView.swift b/Codive/Features/LookBook/Presentation/View/LookBookView.swift index 54cb2926..c5267344 100644 --- a/Codive/Features/LookBook/Presentation/View/LookBookView.swift +++ b/Codive/Features/LookBook/Presentation/View/LookBookView.swift @@ -69,9 +69,7 @@ struct LookBookView: View { Text(TextLiteral.LookBook.noRecovery) } .onAppear { - if viewModel.lookBookList.isEmpty && !viewModel.isLoading { - viewModel.fetchLookBooks() - } + viewModel.fetchLookBooks() } .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) @@ -92,7 +90,7 @@ struct LookBookView: View { rightButton: viewModel.isEditing ? .text( title: TextLiteral.Common.delete, - isEnabled: viewModel.selectedLookBookId != nil, + isEnabled: !viewModel.selectedLookBookIds.isEmpty, action: viewModel.handleCompleteAction ) : .overflow( @@ -177,7 +175,7 @@ private struct LookBookContent: View { count: Int(lookbook.count), imageUrl: lookbook.imageUrl, mode: viewModel.isEditing - ? .check(isSelected: viewModel.selectedLookBookId == lookbook.id) + ? .check(isSelected: viewModel.selectedLookBookIds.contains(lookbook.id)) : .none ) { if viewModel.isEditing { diff --git a/Codive/Features/LookBook/Presentation/View/SpecificLookBookView.swift b/Codive/Features/LookBook/Presentation/View/SpecificLookBookView.swift index ab154211..817fa8de 100644 --- a/Codive/Features/LookBook/Presentation/View/SpecificLookBookView.swift +++ b/Codive/Features/LookBook/Presentation/View/SpecificLookBookView.swift @@ -34,7 +34,7 @@ struct SpecificLookBookView: View { rightButton: viewModel.isEditing ? .text( title: TextLiteral.Common.delete, - isEnabled: viewModel.selectedCodiId != nil, + isEnabled: !viewModel.selectedCodiIds.isEmpty, action: viewModel.handleCompleteAction ) : .overflow( @@ -65,7 +65,7 @@ struct SpecificLookBookView: View { cardTitle: codi.coordinateName, iconType: viewModel.isEditing ? .checkmark : .heart, isSelected: viewModel.isEditing - ? viewModel.selectedCodiId == codi.id + ? viewModel.selectedCodiIds.contains(codi.id) : viewModel.likedCodiId == codi.id ) { if viewModel.isEditing { @@ -94,9 +94,7 @@ struct SpecificLookBookView: View { } } .onAppear { - if viewModel.specificLookBookCodiList.isEmpty { - viewModel.fetchCodis() - } + viewModel.fetchCodis() } } @@ -104,7 +102,7 @@ struct SpecificLookBookView: View { LoadingView(backgroundStyle: .white) } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .alert( diff --git a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift index 6b8917a5..fd2eb0d3 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift @@ -63,12 +63,28 @@ final class AddCodiDetailViewModel: ObservableObject { self.productUseCase = productUseCase setupEditDataSubscription() + setupFilterObservers() Task { await fetchClothItems() } } - + + private func setupFilterObservers() { + Publishers.CombineLatest( + $searchText.debounce(for: 0.5, scheduler: DispatchQueue.main), + $selectedCategory + ) + .dropFirst() + .sink { [weak self] _ in + Task { await self?.fetchClothItems() } + } + .store(in: &cancellables) + } + func fetchClothItems() async { do { - clothItems = try await productUseCase.execute(category: selectedCategory) + clothItems = try await productUseCase.execute( + category: selectedCategory, + searchText: searchText.isEmpty ? nil : searchText + ) } catch { clothItems = [] } diff --git a/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift index 5f03ce3d..a6b1141a 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/LookBookViewModel.swift @@ -28,7 +28,7 @@ final class LookBookViewModel: ObservableObject { @Published var isEditing: Bool = false @Published var isOverflowMenuExpanded: Bool = false - @Published var selectedLookBookId: Int64? + @Published var selectedLookBookIds: Set = [] // MARK: - Published State (Dialog / Alert) @@ -86,16 +86,16 @@ final class LookBookViewModel: ObservableObject { func toggleDeleteMode() { isEditing.toggle() if !isEditing { - selectedLookBookId = nil + selectedLookBookIds.removeAll() } } - + // MARK: - 삭제할 룩북 선택 func toggleSelection(id: Int64) { - if selectedLookBookId == id { - selectedLookBookId = nil + if selectedLookBookIds.contains(id) { + selectedLookBookIds.remove(id) } else { - selectedLookBookId = id + selectedLookBookIds.insert(id) } } @@ -107,7 +107,7 @@ final class LookBookViewModel: ObservableObject { // MARK: - alert 삭제 동작 func handleCompleteAction() { - guard selectedLookBookId != nil else { + guard !selectedLookBookIds.isEmpty else { toggleDeleteMode() return } @@ -127,7 +127,7 @@ final class LookBookViewModel: ObservableObject { // MARK: - 룩북 삭제 확정 func confirmDelete() { - guard let idToDelete = selectedLookBookId else { + guard !selectedLookBookIds.isEmpty else { isLoading = false return } @@ -137,7 +137,9 @@ final class LookBookViewModel: ObservableObject { Task { do { - try await listUseCase.deleteLookBook(lookBookId: idToDelete) + for id in selectedLookBookIds { + try await listUseCase.deleteLookBook(lookBookId: id) + } let updatedResult = try await listUseCase.fetchLookBookList( lastLookBookId: nil, diff --git a/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift index 8ad83ed1..62de277c 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/SpecificLookBookViewModel.swift @@ -15,7 +15,13 @@ final class SpecificLookBookViewModel: ObservableObject { @Published var isOverflowMenuExpanded: Bool = false private let lookbookId: Int64 - @Published var name: String + @Published var name: String { + didSet { + if name.count > 10 { + name = String(name.prefix(10)) + } + } + } @Published var isEditingTitle = false private var previousTitle: String = "" @@ -24,7 +30,7 @@ final class SpecificLookBookViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var isEditing: Bool = false - @Published var selectedCodiId: Int64? + @Published var selectedCodiIds: Set = [] @Published var isShowingDeleteAlert: Bool = false // MARK: - Initializer @@ -94,18 +100,18 @@ final class SpecificLookBookViewModel: ObservableObject { } func toggleSelection(id: Int64) { - if selectedCodiId == id { - selectedCodiId = nil + if selectedCodiIds.contains(id) { + selectedCodiIds.remove(id) } else { - selectedCodiId = id + selectedCodiIds.insert(id) } } - + // MARK: - 토글(편집하기) func toggleEditingMode() { isEditing.toggle() if !isEditing { - selectedCodiId = nil + selectedCodiIds.removeAll() } } @@ -117,46 +123,48 @@ final class SpecificLookBookViewModel: ObservableObject { // MARK: - topBar 삭제 버튼 동작 func handleCompleteAction() { - guard selectedCodiId != nil else { + guard !selectedCodiIds.isEmpty else { toggleEditingMode() return } isShowingDeleteAlert = true } - + // MARK: - alert 삭제 버튼 func beginDelete() { isShowingDeleteAlert = false isLoading = true - + Task { try? await Task.sleep(nanoseconds: 150_000_000) self.confirmDelete() } } - + // MARK: - 코디 삭제 확정 func confirmDelete() { - guard let idToDelete = selectedCodiId else { + guard !selectedCodiIds.isEmpty else { isLoading = false isEditing = false return } - + Task { do { - try await specificLookBookUseCase.deleteCoordinate(coordinateId: idToDelete) - + for id in selectedCodiIds { + try await specificLookBookUseCase.deleteCoordinate(coordinateId: id) + } + specificLookBookCodiList.removeAll { codi in - codi.id == idToDelete + selectedCodiIds.contains(codi.id) } - - selectedCodiId = nil + + selectedCodiIds.removeAll() isEditing = false } catch { errorMessage = "코디 삭제에 실패했습니다." } - + isLoading = false } } diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index e5420397..5bab53ac 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -25,6 +25,7 @@ struct MainTabView: View { private let settingDIContainer: SettingDIContainer private let profileDIContainer: ProfileDIContainer private let reportDIContainer: ReportDIContainer + @ObservedObject private var profileViewModel: ProfileViewModel // MARK: - Initializer init(appDIContainer: AppDIContainer) { @@ -44,7 +45,7 @@ struct MainTabView: View { self._navigationRouter = ObservedObject(wrappedValue: appDIContainer.navigationRouter) let checkTodayRecordUseCase = CheckTodayRecordUseCase( historyRepository: HistoryRepositoryImpl() - ) { TokenService().getCurrentUserId() } + ) let viewModel = MainTabViewModel( navigationRouter: appDIContainer.navigationRouter, notificationUsecase: notificationDIContainer.topNavigationNotificaionUsecase, @@ -52,6 +53,7 @@ struct MainTabView: View { ) self._viewModel = StateObject(wrappedValue: viewModel) self.homeViewModel = homeDIContainer.makeHomeViewModel() + self._profileViewModel = ObservedObject(wrappedValue: profileDIContainer.makeProfileViewModel()) } // MARK: - Body @@ -76,7 +78,20 @@ struct MainTabView: View { Group { switch viewModel.selectedTab { case .home: - HomeView(homeDIContainer: homeDIContainer, viewModel: homeViewModel) + HomeView( + homeDIContainer: homeDIContainer, + viewModel: homeViewModel, + onBannerTapped: { + Task { + let hasTodayRecord = await viewModel.checkTodayRecordExists() + if hasTodayRecord { + viewModel.isDuplicateRecordModalPresented = true + } else { + homeViewModel.handleBannerRecord() + } + } + } + ) .ignoresSafeArea(.all, edges: .bottom) case .closet: ClosetView(closetDIContainer: closetDIContainer) @@ -144,6 +159,16 @@ struct MainTabView: View { } } .environmentObject(navigationRouter) + .onChange(of: viewModel.selectedTab) { newTab in + if newTab == .home { + homeViewModel.onAppear() + if homeViewModel.weatherData != nil { + Task { + await homeViewModel.loadRecommendCategoryClothList(seasons: homeViewModel.currentSeasons) + } + } + } + } .onReceive(navigationRouter.$pendingTabSwitch) { tab in if let tab = tab { viewModel.selectedTab = tab @@ -188,6 +213,19 @@ struct MainTabView: View { .zIndex(301) } + // MARK: - Favorite Codi Popup Overlay + if profileViewModel.isShowingPopup, let preview = profileViewModel.selectedCoordinatePreview { + FavoriteLookBookPopUp( + imageUrl: preview.imageUrl, + clothItems: profileViewModel.popupClothItems, + payloads: profileViewModel.popupPayloads + ) { + profileViewModel.isShowingPopup = false + } + .ignoresSafeArea() + .zIndex(500) + } + // MARK: - Empty History Modal Overlay if viewModel.isEmptyHistoryModalPresented { Color.black @@ -206,9 +244,10 @@ struct MainTabView: View { viewModel.emptyHistoryModalDate = nil }, onAddRecord: { + let selectedDate = viewModel.emptyHistoryModalDate viewModel.isEmptyHistoryModalPresented = false viewModel.emptyHistoryModalDate = nil - viewModel.checkAndNavigateToRecordAdd() + viewModel.checkAndNavigateToRecordAdd(selectedDate: selectedDate) } ) .frame(height: 310, alignment: .center) @@ -218,6 +257,17 @@ struct MainTabView: View { } .onAppear { viewModel.loadNotificationExist() + + // 앱이 죽어있을 때 푸시 탭으로 실행된 경우 처리 + if let pendingUserInfo = AppDelegate.pendingPushUserInfo { + AppDelegate.pendingPushUserInfo = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + handlePushNotificationTap(userInfo: pendingUserInfo) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .pushNotificationTapped)) { notification in + handlePushNotificationTap(userInfo: notification.userInfo) } .environmentObject(viewModel) } @@ -253,6 +303,33 @@ struct MainTabView: View { true } + // MARK: - Push Notification Redirect + + private func handlePushNotificationTap(userInfo: [AnyHashable: Any]?) { + guard let userInfo else { return } + + let destination: AppDestination? + + if let historyId = userInfo["historyId"] as? Int { + destination = .feedDetail(feedId: historyId) + } else if let historyIdStr = userInfo["historyId"] as? String, let historyId = Int(historyIdStr) { + destination = .feedDetail(feedId: historyId) + } else if let memberId = userInfo["memberId"] as? Int { + destination = .otherProfile(userId: memberId) + } else if let memberIdStr = userInfo["memberId"] as? String, let memberId = Int(memberIdStr) { + destination = .otherProfile(userId: memberId) + } else { + destination = nil + } + + guard let destination else { return } + + navigationRouter.navigateToRoot() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + navigationRouter.navigate(to: destination) + } + } + // swiftlint:disable cyclomatic_complexity @ViewBuilder private func destinationView(for destination: AppDestination) -> some View { @@ -304,7 +381,8 @@ struct MainTabView: View { // MARK: - Closet case .myCloset: closetDIContainer.makeMyClosetView() - case .clothDetail, .clothEdit, .wardrobeReport: + case .clothDetail, .clothEdit, .wardrobeReport, + .wardrobeFavoriteCategory, .wardrobeItemStats, .wardrobeUsageCheck: closetDIContainer.closetViewFactory.makeView(for: destination) // MARK: - Add / LookBook / Report diff --git a/Codive/Features/Main/ViewModel/MainTabViewModel.swift b/Codive/Features/Main/ViewModel/MainTabViewModel.swift index 71085c7f..a6a0e70e 100644 --- a/Codive/Features/Main/ViewModel/MainTabViewModel.swift +++ b/Codive/Features/Main/ViewModel/MainTabViewModel.swift @@ -36,13 +36,21 @@ final class MainTabViewModel: ObservableObject { // MARK: - Duplicate Record Check /// 오늘 기록이 있는지 확인 후, 없으면 기록 추가 화면으로 이동, 있으면 모달 표시 - func checkAndNavigateToRecordAdd() { + /// - Note: 중복 체크는 "오늘" 기록을 작성하는 경우(selectedDate가 nil이거나 오늘)에만 수행한다. + /// 달력에서 과거 등 특정 날짜를 선택해 들어온 경우에는 해당 날짜에 기록이 없는 것이 + /// 이미 보장되므로 오늘 기록 여부와 무관하게 바로 기록 추가 화면으로 이동한다. + func checkAndNavigateToRecordAdd(selectedDate: Date? = nil) { + let isRecordingForToday = selectedDate.map { Calendar.current.isDateInToday($0) } ?? true + guard isRecordingForToday else { + navigationRouter.navigate(to: .recordAdd(selectedDate: selectedDate)) + return + } Task { let hasTodayRecord = await checkTodayRecordUseCase.execute() if hasTodayRecord { isDuplicateRecordModalPresented = true } else { - navigationRouter.navigate(to: .recordAdd) + navigationRouter.navigate(to: .recordAdd(selectedDate: selectedDate)) } } } @@ -54,14 +62,10 @@ final class MainTabViewModel: ObservableObject { // MARK: - Actions func handleSearchTap() { - // 검색 버튼 탭 처리 - // TODO: 검색 화면으로 이동하거나 검색 로직 처리 navigationRouter.navigate(to: .search) } - + func handleNotificationTap() { - // 알림 버튼 탭 처리 - // TODO: 알림 화면으로 이동하거나 알림 로직 처리 navigationRouter.navigate(to: .notification) } diff --git a/Codive/Features/Notification/Presentation/View/NotificationView.swift b/Codive/Features/Notification/Presentation/View/NotificationView.swift index 471919c2..d002f6b7 100644 --- a/Codive/Features/Notification/Presentation/View/NotificationView.swift +++ b/Codive/Features/Notification/Presentation/View/NotificationView.swift @@ -49,7 +49,7 @@ struct NotificationView: View { .padding(.horizontal, 20) } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white.ignoresSafeArea(.all)) // MARK: - Data Loading Trigger @@ -82,7 +82,7 @@ struct NotificationView: View { if item.readStatus == .notRead { viewModel.markAsRead(notificationId: item.notificationId) } - // redirect 처리 위치 + viewModel.handleNotificationTap(notification: item) } } } diff --git a/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift b/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift index feb450a9..520778d2 100644 --- a/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift +++ b/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift @@ -86,4 +86,17 @@ final class NotificationViewModel: ObservableObject { func handleBackTap() { navigationRouter.navigateBack() } + + func handleNotificationTap(notification: NotificationListResponseItem) { + switch notification.action.redirectType { + case .historyRedirect: + guard let feedId = Int(notification.action.redirectInfo) else { return } + navigationRouter.navigate(to: .feedDetail(feedId: feedId)) + case .memberRedirect: + guard let userId = Int(notification.action.redirectInfo) else { return } + navigationRouter.navigate(to: .otherProfile(userId: userId)) + case .none: + break + } + } } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 6448bd03..e95c7857 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -69,7 +69,7 @@ struct ProfileSettingView: View { } .background(Color.white) } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .onAppear { Task { diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index b834fd74..298ec0de 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -31,10 +31,8 @@ struct ProfileView: View { .padding(.top, 24) .foregroundStyle(Color.Codive.grayscale7) - if !viewModel.favoriteCoordinates.isEmpty { - favoriteCodiSection - .padding(.top, 24) - } + favoriteCodiSection + .padding(.top, 24) calendarSection .padding(.top, 40) @@ -47,17 +45,6 @@ struct ProfileView: View { await viewModel.loadMyProfile() } - if viewModel.isShowingPopup, let preview = viewModel.selectedCoordinatePreview { - FavoriteLookBookPopUp( - imageUrl: preview.imageUrl, - clothItems: viewModel.popupClothItems, - payloads: viewModel.popupPayloads - ) { - viewModel.isShowingPopup = false - } - .transition(.opacity.combined(with: .scale)) - .zIndex(1) - } } .background(Color.white) .navigationBarBackButtonHidden(!navigationRouter.path.isEmpty) @@ -213,29 +200,66 @@ struct ProfileView: View { } .padding(.horizontal, 20) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(viewModel.favoriteCoordinates, id: \.coordinateId) { codi in - CodiCard( - imageURL: URL(string: codi.imageUrl), - title: nil, - icon: .heart(isSelected: true) {}, - cardWidth: 160, - imageSize: 160, - cornerRadius: 16, - iconPadding: 14, - iconSize: 20 - ) { - viewModel.onCodiCardTapped(coordinateId: Int64(codi.coordinateId)) + if viewModel.favoriteCoordinates.isEmpty { + favoriteCodiEmptyCard + .padding(.horizontal, 20) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(viewModel.favoriteCoordinates, id: \.coordinateId) { codi in + CodiCard( + imageURL: URL(string: codi.imageUrl), + title: nil, + icon: .heart(isSelected: true) {}, + cardWidth: 160, + imageSize: 160, + cornerRadius: 16, + iconPadding: 14, + iconSize: 20 + ) { + viewModel.onCodiCardTapped(coordinateId: Int64(codi.coordinateId)) + } } } + .padding(.top, 12) } - .padding(.top, 12) + .padding(.horizontal, 20) } - .padding(.horizontal, 20) } } - + + private var favoriteCodiEmptyCard: some View { + VStack(spacing: 12) { + Text("최애 코디가 아직 없어요") + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + + Text("옷장에서 좋아하는 코디에 하트를 눌러\n최애 코디를 채워보세요!") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale3) + .multilineTextAlignment(.center) + + CustomButton( + text: "옷장으로 이동하기", + widthType: .dynamic, + styleType: .fill + ) { + viewModel.navigateToCloset() + } + } + .padding(.vertical, 24) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.Codive.grayscale6, lineWidth: 1) + ) + ) + } + // MARK: - Calendar private var calendarSection: some View { VStack(alignment: .leading, spacing: 12) { diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index f46d22e1..e508005e 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -32,7 +32,9 @@ class ProfileViewModel: ObservableObject { imageName: detail.imageUrl, brand: detail.brand, name: detail.name, - clothId: detail.clothId + clothId: detail.clothId, + category: detail.category, + parentCategory: detail.parentCategory ) } } @@ -203,6 +205,10 @@ class ProfileViewModel: ObservableObject { func onMoreFavoriteCodiTapped() { navigationRouter.navigate(to: .favoriteCodiList(showHeart: true, memberId: nil)) } + + func navigateToCloset() { + navigationRouter.switchTabAndNavigate(to: .closet) + } func loadFavoriteCoordinates() async { do { diff --git a/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift b/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift index 470e4afa..0f906fc7 100644 --- a/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift +++ b/Codive/Features/Profile/Shared/Presentation/Components/FavoriteLookBookPopUp.swift @@ -86,7 +86,10 @@ private extension FavoriteLookBookPopUp { // MARK: - 태그 표시 조건 수정 if showClothSelector, let item = selectedItem, let pos = payload { - CustomTagView(type: .basic(title: item.brand, content: item.name)) + CustomTagView(type: .basic( + title: item.brand.isEmpty ? "No brand" : item.brand, + content: item.name.isEmpty ? "\(item.parentCategory) > \(item.category)" : item.name + )) .position( x: boardSize * CGFloat(pos.locationX), y: boardSize * CGFloat(pos.locationY) @@ -132,7 +135,7 @@ private extension FavoriteLookBookPopUp { .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(selectedIndex == index ? Color.blue : Color.clear, lineWidth: 2) + .stroke(selectedIndex == index ? Color.Codive.main0 : Color.clear, lineWidth: 2) ) .onTapGesture { selectedIndex = index diff --git a/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift b/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift index 70085cb7..2d077c38 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift @@ -31,40 +31,57 @@ struct FavoriteCodiView: View { } var body: some View { - VStack(spacing: 0) { - CustomNavigationBar( - title: "최애 코디", - onBack: { navigationRouter.navigateBack() }, - rightButton: .none - ) - - ScrollView(showsIndicators: false) { - if viewModel.isLoading && viewModel.favoriteCoordinates.isEmpty { - ProgressView() - .padding(.top, 50) - } else { - LazyVGrid(columns: columns, spacing: 32) { - ForEach(viewModel.favoriteCoordinates, id: \.coordinateId) { coordinate in - CodiCard( - imageURL: URL(string: coordinate.imageUrl), - title: coordinate.coordinateName, - icon: .heart(isSelected: true) {}, - cardWidth: 160, - imageSize: 160, - cornerRadius: 16, - iconPadding: 14, - iconSize: 20 - ) {} + ZStack { + VStack(spacing: 0) { + CustomNavigationBar( + title: "최애 코디", + onBack: { navigationRouter.navigateBack() }, + rightButton: .none + ) + + ScrollView(showsIndicators: false) { + if viewModel.isLoading && viewModel.favoriteCoordinates.isEmpty { + ProgressView() + .padding(.top, 50) + } else { + LazyVGrid(columns: columns, spacing: 32) { + ForEach(viewModel.favoriteCoordinates, id: \.coordinateId) { coordinate in + CodiCard( + imageURL: URL(string: coordinate.imageUrl), + title: coordinate.coordinateName, + icon: .heart(isSelected: true) {}, + cardWidth: 160, + imageSize: 160, + cornerRadius: 16, + iconPadding: 14, + iconSize: 20 + ) { + viewModel.onCodiCardTapped(coordinateId: Int64(coordinate.coordinateId)) + } + } } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 24) } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 24) } } + + if viewModel.isShowingPopup, let preview = viewModel.selectedCoordinatePreview { + FavoriteLookBookPopUp( + imageUrl: preview.imageUrl, + clothItems: viewModel.popupClothItems, + payloads: viewModel.popupPayloads + ) { + viewModel.isShowingPopup = false + } + .ignoresSafeArea() + .transition(.opacity.combined(with: .scale)) + .zIndex(1) + } } .background(Color.white) - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .task { await viewModel.loadFavoriteCoordinates(memberId: memberId) diff --git a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift index e2634b67..9f443321 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift @@ -24,27 +24,48 @@ struct FollowListView: View { rightButton: .none ) - ScrollView { - LazyVStack(spacing: 16) { - ForEach(viewModel.items) { item in - CustomUserRow( - user: item.user, - buttonTitle: item.buttonTitle, - buttonStyle: item.buttonStyle - ) { - viewModel.onTapButton(userId: item.user.userId) - } - .padding(.top, 4) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.onTapProfile(userId: item.user.userId) + if viewModel.isLoading { + Spacer() + ProgressView() + Spacer() + } else if let errorMessage = viewModel.errorMessage { + Spacer() + Text(errorMessage) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + .multilineTextAlignment(.center) + Spacer() + } else if viewModel.items.isEmpty { + Spacer() + Text(viewModel.mode == .followers + ? "아직 팔로워가 없어요" + : "아직 팔로잉이 없어요") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.items) { item in + CustomUserRow( + user: item.user, + buttonTitle: item.buttonTitle, + buttonStyle: item.buttonStyle + ) { + viewModel.onTapButton(userId: item.user.userId) + } + .padding(.top, 4) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.onTapProfile(userId: item.user.userId) + } } } + .padding(.top, 12) + .padding(.bottom, 24) } - .padding(.top, 12) - .padding(.bottom, 24) + .scrollIndicators(.hidden) } - .scrollIndicators(.hidden) } .background(Color.white) .navigationBarBackButtonHidden(true) diff --git a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift index 30eabb57..63993a30 100644 --- a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift +++ b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FavoriteCodiViewModel.swift @@ -12,7 +12,38 @@ final class FavoriteCodiViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var favoriteCoordinates: [MyFavoriteLookBookResponseDTO] = [] - + + @Published var isShowingPopup: Bool = false + @Published var selectedCoordinatePreview: CoordinatePreviewEntity? + @Published var selectedCoordinateDetails: [CoordinateDetailEntity] = [] + + var popupClothItems: [CodiItem] { + selectedCoordinateDetails.map { detail in + CodiItem( + id: detail.coordinateClothId, + imageName: detail.imageUrl, + brand: detail.brand, + name: detail.name, + clothId: detail.clothId, + category: detail.category, + parentCategory: detail.parentCategory + ) + } + } + + var popupPayloads: [Payloads] { + selectedCoordinateDetails.map { detail in + Payloads( + clothId: detail.clothId, + locationX: detail.locationX, + locationY: detail.locationY, + ratio: detail.ratio, + degree: detail.degree, + order: detail.order + ) + } + } + private let navigationRouter: NavigationRouter private let fetchMyFavoriteLookBookUseCase: FetchMyFavoriteLookBookUseCase @@ -40,4 +71,18 @@ final class FavoriteCodiViewModel: ObservableObject { isLoading = false } + + func onCodiCardTapped(coordinateId: Int64) { + Task { + do { + self.selectedCoordinatePreview = try await fetchMyFavoriteLookBookUseCase.fetchCoordinatePreview(coordinateId: coordinateId) + self.selectedCoordinateDetails = try await fetchMyFavoriteLookBookUseCase.fetchCoordinateDetail(coordinateId: coordinateId) + self.isShowingPopup = true + } catch { + #if DEBUG + print("[FavoriteCodi] 코디 상세 로드 실패: \(error)") + #endif + } + } + } } diff --git a/Codive/Features/Report/Presentation/View/ReportDetailView.swift b/Codive/Features/Report/Presentation/View/ReportDetailView.swift index 0470ad38..42d609e6 100644 --- a/Codive/Features/Report/Presentation/View/ReportDetailView.swift +++ b/Codive/Features/Report/Presentation/View/ReportDetailView.swift @@ -96,7 +96,7 @@ struct ReportDetailView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .alert("신고 안내", isPresented: $vm.showDuplicateAlert) { Button("확인") { diff --git a/Codive/Features/Report/Presentation/View/ReportView.swift b/Codive/Features/Report/Presentation/View/ReportView.swift index c00a5b85..248b381c 100644 --- a/Codive/Features/Report/Presentation/View/ReportView.swift +++ b/Codive/Features/Report/Presentation/View/ReportView.swift @@ -35,7 +35,7 @@ struct ReportView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .task { await vm.loadContext() } } diff --git a/Codive/Features/Search/Presentation/View/RecentlySearchResultView.swift b/Codive/Features/Search/Presentation/View/RecentlySearchResultView.swift index 95fbddae..fb50b493 100644 --- a/Codive/Features/Search/Presentation/View/RecentlySearchResultView.swift +++ b/Codive/Features/Search/Presentation/View/RecentlySearchResultView.swift @@ -44,7 +44,7 @@ struct RecentlySearchResultView: View { .padding(.horizontal, 15) } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white.ignoresSafeArea(.all)) .onAppear { diff --git a/Codive/Features/Search/Presentation/View/SearchResultView.swift b/Codive/Features/Search/Presentation/View/SearchResultView.swift index ce6fee26..b2504c30 100644 --- a/Codive/Features/Search/Presentation/View/SearchResultView.swift +++ b/Codive/Features/Search/Presentation/View/SearchResultView.swift @@ -74,7 +74,7 @@ struct SearchResultView: View { hideKeyboard() } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .background( Color.white .ignoresSafeArea(.all) diff --git a/Codive/Features/Search/Presentation/View/SearchView.swift b/Codive/Features/Search/Presentation/View/SearchView.swift index d15d8b91..039fca7a 100644 --- a/Codive/Features/Search/Presentation/View/SearchView.swift +++ b/Codive/Features/Search/Presentation/View/SearchView.swift @@ -114,7 +114,7 @@ struct SearchView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .background( Color.white .ignoresSafeArea(.all) diff --git a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift index 0b8664f7..4bad647c 100644 --- a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift +++ b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift @@ -11,11 +11,8 @@ final class SettingsDataSource { // MARK: - Properties private let apiClient: Client - // MARK: - In-memory stores - private var notificationPrefsStore: NotificationPrefs = .init( - pushEnabled: true, - marketingOptIn: false - ) + // MARK: - UserDefaults Keys + private static let marketingOptInKey = "SettingMarketingOptIn" private let withdrawNoticesStore: [WithdrawNotice] = [ .init(title: "데이터 삭제 안내", @@ -202,11 +199,12 @@ final class SettingsDataSource { // MARK: - Notification Prefs func getNotificationPrefs() async throws -> NotificationPrefs { - notificationPrefsStore + let marketingOptIn = UserDefaults.standard.bool(forKey: Self.marketingOptInKey) + return NotificationPrefs(pushEnabled: false, marketingOptIn: marketingOptIn) } func updateNotificationPrefs(_ prefs: NotificationPrefs) async throws { - notificationPrefsStore = prefs + UserDefaults.standard.set(prefs.marketingOptIn, forKey: Self.marketingOptInKey) } // MARK: - Withdraw diff --git a/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift b/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift index dac1d420..5ceb0c0b 100644 --- a/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift @@ -35,7 +35,7 @@ struct SettingBlockedView: View { } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() } diff --git a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift index e3f06a0f..7da3fffb 100644 --- a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift @@ -134,7 +134,7 @@ struct SettingCommentView: View { .task { await vm.refresh() } .refreshable { await vm.refresh() } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() } } diff --git a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift index 6621c6ac..bbc6563f 100644 --- a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift @@ -76,7 +76,7 @@ struct SettingLikedView: View { .refreshable { await vm.refresh() } } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() } } diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index 6ee12813..727573a0 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -35,11 +35,16 @@ struct SettingView: View { .padding(.top, 24) } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .task { await vm.load() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + Task { + await vm.refreshPushPermissionStatus() + } + } } // MARK: - 섹션: 로그인 / 회원정보 @@ -126,11 +131,10 @@ struct SettingView: View { Spacer() - // ViewModel 상태와 직접 바인딩 + 변경 시 저장 PillToggle( isOn: Binding( get: { vm.isPushOn }, - set: { vm.updatePush($0) } + set: { _ in vm.openPushSettings() } ) ) } diff --git a/Codive/Features/Setting/Presentation/View/WithdrawView.swift b/Codive/Features/Setting/Presentation/View/WithdrawView.swift index 1f238496..4189e583 100644 --- a/Codive/Features/Setting/Presentation/View/WithdrawView.swift +++ b/Codive/Features/Setting/Presentation/View/WithdrawView.swift @@ -57,7 +57,7 @@ struct WithdrawView: View { .padding(.bottom, 20) } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .alert(TextLiteral.Setting.withdrawConfirmTitle, isPresented: $vm.showConfirmAlert) { Button("취소", role: .cancel) { } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index e8480486..9e58bbb7 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -1,4 +1,6 @@ import Foundation +import UserNotifications +import UIKit @MainActor final class SettingViewModel: ObservableObject { @@ -43,8 +45,11 @@ final class SettingViewModel: ObservableObject { // 프로필 정보 로드 await profileViewModel.loadMyProfile() + // 시스템 푸시 권한 상태 확인 + await refreshPushPermissionStatus() + + // 마케팅 동의 설정 로드 let prefs = try await getPrefsUC.fetch() - isPushOn = prefs.pushEnabled isMarketingOn = prefs.marketingOptIn hasLoaded = true } catch { @@ -54,19 +59,28 @@ final class SettingViewModel: ObservableObject { isLoading = false } - // 토글 핸들러 - func updatePush(_ newValue: Bool) { - isPushOn = newValue - Task { await save() } + // MARK: - Push Notification + + /// 시스템 알림 권한 상태를 확인하여 토글에 반영 + func refreshPushPermissionStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + isPushOn = settings.authorizationStatus == .authorized } + /// 푸시 토글 탭 시 iOS 설정 앱으로 이동 + func openPushSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + + // MARK: - Marketing + func updateMarketing(_ newValue: Bool) { isMarketingOn = newValue - Task { await save() } + Task { await saveMarketingPrefs() } } - // 서버 저장 - private func save() async { + private func saveMarketingPrefs() async { let prefs = NotificationPrefs( pushEnabled: isPushOn, marketingOptIn: isMarketingOn @@ -97,8 +111,8 @@ final class SettingViewModel: ObservableObject { } func navigateToInquiry() { - // TODO: 문의하기 화면으로 이동 - // 아직 구현되지 않은 화면입니다. + guard let url = URL(string: "https://pf.kakao.com/_amHbn") else { return } + UIApplication.shared.open(url) } func navigateToWithdraw() { diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png index 61892789..6fddf8c6 100644 Binary files a/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png and b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png differ diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index 2292b795..7ddc1646 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -12,12 +12,12 @@ enum AppDestination: Hashable, Identifiable { case signup case termsAgreement case main - case recordAdd + case recordAdd(selectedDate: Date? = nil) case clothPhotoSelect case clothAdd(photos: [SelectedPhoto], isAIEnabled: Bool = false) case photoEdit(photos: [SelectedPhoto]) case photoEditForCloth(photos: [SelectedPhoto], isAIEnabled: Bool = false) - case recordDetail(photos: [SelectedPhoto]) + case recordDetail(photos: [SelectedPhoto], selectedDate: Date? = nil) case recordEdit(feed: Feed) case photoTag(photo: SelectedPhoto, allPhotos: [SelectedPhoto]) case settings @@ -51,6 +51,9 @@ enum AppDestination: Hashable, Identifiable { case clothDetail(cloth: Cloth) case clothEdit(cloth: Cloth) case wardrobeReport + case wardrobeFavoriteCategory(parentCategoryId: Int64) + case wardrobeItemStats + case wardrobeUsageCheck case eraserEditor(photo: SelectedPhoto, photoIndex: Int) case eraserPreview(photoIndex: Int) case profileSetting @@ -89,7 +92,9 @@ enum AppDestination: Hashable, Identifiable { return true // Closet Flow - 전체 화면 - case .myCloset, .clothDetail, .clothEdit, .wardrobeReport, .eraserEditor, .eraserPreview: + case .myCloset, .clothDetail, .clothEdit, .wardrobeReport, + .wardrobeFavoriteCategory, .wardrobeItemStats, .wardrobeUsageCheck, + .eraserEditor, .eraserPreview: return true // 다른 플로우 전체 화면은 여기에 추가 @@ -127,7 +132,9 @@ enum AppDestination: Hashable, Identifiable { return false // Closet Flow - 자체 네비게이션 바 있음 - case .myCloset, .clothDetail, .clothEdit, .wardrobeReport, .eraserEditor, .eraserPreview: + case .myCloset, .clothDetail, .clothEdit, .wardrobeReport, + .wardrobeFavoriteCategory, .wardrobeItemStats, .wardrobeUsageCheck, + .eraserEditor, .eraserPreview: return false default: diff --git a/Codive/Router/AppRouter.swift b/Codive/Router/AppRouter.swift index fd2f0fcf..03183846 100644 --- a/Codive/Router/AppRouter.swift +++ b/Codive/Router/AppRouter.swift @@ -59,8 +59,8 @@ final class AppRouter: ObservableObject { } func finishSplash() { - // 스플래시 종료 후 인증 화면으로 이동 - // TODO: 로그인 상태 확인 로직 추가 (토큰 있으면 .main) + // 스플래시 종료 후 인증 화면으로 이동. + // 자동 로그인 분기는 SplashViewModel 에서 처리한다. currentAppState = .auth } @@ -74,10 +74,7 @@ final class AppRouter: ObservableObject { currentAppState = .main #if DEBUG if let token = try? KeychainManager.shared.getAccessToken() { - print("----------------------------------------") - print("[App] Access Token:") - print(token) - print("----------------------------------------") + AppLog.app.debug("Access Token on main entry: \(token.masked(), privacy: .public)") } #endif } diff --git a/Codive/Router/ViewFactory/AddViewFactory.swift b/Codive/Router/ViewFactory/AddViewFactory.swift index ba2aed92..233131b4 100644 --- a/Codive/Router/ViewFactory/AddViewFactory.swift +++ b/Codive/Router/ViewFactory/AddViewFactory.swift @@ -22,8 +22,8 @@ final class AddViewFactory { @ViewBuilder func makeView(for destination: AppDestination) -> some View { switch destination { - case .recordAdd: - addDIContainer?.makeRecordAddView(flowType: .record) + case .recordAdd(let selectedDate): + makeRecordAddViewWithDate(selectedDate: selectedDate) case .clothPhotoSelect: addDIContainer?.makeRecordAddView(flowType: .cloth) case .clothAdd(let photos, let isAIEnabled): @@ -32,8 +32,8 @@ final class AddViewFactory { addDIContainer?.makePhotoEditView(selectedPhotos: photos, flowType: .record) case .photoEditForCloth(let photos, let isAIEnabled): addDIContainer?.makePhotoEditView(selectedPhotos: photos, flowType: .cloth, isAIEnabled: isAIEnabled) - case .recordDetail(let photos): - addDIContainer?.makeRecordDetailView(selectedPhotos: photos) + case .recordDetail(let photos, let selectedDate): + addDIContainer?.makeRecordDetailView(selectedPhotos: photos, selectedDate: selectedDate ?? addDIContainer?.selectedDate) case .recordEdit(let feed): addDIContainer?.makeRecordDetailViewForEdit(feed: feed) case .photoTag(let photo, let allPhotos): @@ -46,4 +46,9 @@ final class AddViewFactory { EmptyView() } } + + private func makeRecordAddViewWithDate(selectedDate: Date?) -> RecordAddView? { + addDIContainer?.selectedDate = selectedDate + return addDIContainer?.makeRecordAddView(flowType: .record) + } } diff --git a/Codive/Router/ViewFactory/ClosetViewFactory.swift b/Codive/Router/ViewFactory/ClosetViewFactory.swift index b8aed9d7..3204f622 100644 --- a/Codive/Router/ViewFactory/ClosetViewFactory.swift +++ b/Codive/Router/ViewFactory/ClosetViewFactory.swift @@ -30,6 +30,12 @@ final class ClosetViewFactory { closetDIContainer?.makeClothEditView(cloth: cloth) case .wardrobeReport: closetDIContainer?.makeWardrobeReportDetailView() + case .wardrobeFavoriteCategory(let parentCategoryId): + closetDIContainer?.makeFavoriteByCategoryView(parentCategoryId: parentCategoryId) + case .wardrobeItemStats: + closetDIContainer?.makeItemDataView() + case .wardrobeUsageCheck: + closetDIContainer?.makeWearingDataView() default: EmptyView() } diff --git a/Codive/Shared/Data/Network/CodiveDateTranscoder.swift b/Codive/Shared/Data/Network/CodiveDateTranscoder.swift index 4d534a77..c691a3b6 100644 --- a/Codive/Shared/Data/Network/CodiveDateTranscoder.swift +++ b/Codive/Shared/Data/Network/CodiveDateTranscoder.swift @@ -35,7 +35,8 @@ struct CodiveDateTranscoder: DateTranscoder { "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss" + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd" ] for format in formats { diff --git a/Codive/Shared/Data/Network/JSONDecoderFactory.swift b/Codive/Shared/Data/Network/JSONDecoderFactory.swift index f5e99eb3..85e87a3a 100644 --- a/Codive/Shared/Data/Network/JSONDecoderFactory.swift +++ b/Codive/Shared/Data/Network/JSONDecoderFactory.swift @@ -37,7 +37,8 @@ enum JSONDecoderFactory { "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", // 7자리 "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", // 6자리 "yyyy-MM-dd'T'HH:mm:ss.SSS", // 3자리 - "yyyy-MM-dd'T'HH:mm:ss" // 소수점 없음 + "yyyy-MM-dd'T'HH:mm:ss", // 소수점 없음 + "yyyy-MM-dd" // 날짜만 ] for format in formats { diff --git a/Codive/Shared/Data/Storage/KeychainManager.swift b/Codive/Shared/Data/Storage/KeychainManager.swift index e941d2aa..e3df54d9 100644 --- a/Codive/Shared/Data/Storage/KeychainManager.swift +++ b/Codive/Shared/Data/Storage/KeychainManager.swift @@ -34,10 +34,7 @@ final class KeychainManager { func saveAccessToken(_ token: String) throws { try save(token, forKey: accessTokenKey) #if DEBUG - print("----------------------------------------") - print("[Keychain] Access Token Saved:") - print(token) - print("----------------------------------------") + AppLog.auth.debug("Access Token Saved: \(token.masked(), privacy: .public)") #endif } diff --git a/Codive/Shared/DesignSystem/Alerts/CustomBanner.swift b/Codive/Shared/DesignSystem/Alerts/CustomBanner.swift index 5096dc6b..69776c6e 100644 --- a/Codive/Shared/DesignSystem/Alerts/CustomBanner.swift +++ b/Codive/Shared/DesignSystem/Alerts/CustomBanner.swift @@ -9,17 +9,17 @@ import SwiftUI struct CustomBanner: View { let text: String - let onIconTap: () -> Void - + let onTap: () -> Void + var body: some View { - HStack { - Text(text) - .font(Font.codive_body2_medium) - .foregroundStyle(Color.Codive.grayscale1) - - Spacer() - - Button(action: onIconTap) { + Button(action: onTap) { + HStack { + Text(text) + .font(Font.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer() + Image(systemName: "chevron.right") .foregroundStyle(.white) .font(.system(size: 12, weight: .bold)) @@ -27,22 +27,23 @@ struct CustomBanner: View { .background(Color.Codive.main1) .clipShape(Circle()) } + .padding(.horizontal, 20) + .padding(.vertical, 24) + .background(Color.Codive.main6) + .clipShape(RoundedRectangle(cornerRadius: 10)) } - .padding(.horizontal, 20) - .padding(.vertical, 24) - .background(Color.Codive.main6) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .buttonStyle(.plain) } } #Preview { CustomBanner(text: "오늘 이 코디를 기억하고 싶다면?") { - print("Icon tapped!") + print("Tapped!") } .padding() - + CustomBanner(text: "패션트렌드 편지가 도착했어요") { - print("Icon tapped!") + print("Tapped!") } .padding() } diff --git a/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift b/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift index 67fa32c8..8a4a4882 100644 --- a/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift +++ b/Codive/Shared/DesignSystem/Buttons/CustomOverflowMenu.swift @@ -161,18 +161,14 @@ private extension CustomOverflowMenu { .renderingMode(.template) .resizable() .scaledToFit() - .aspectRatio(1, contentMode: .fit) - .frame(width: 15, height: 15) case .asset(let name): Image(name) .resizable() .scaledToFit() - .aspectRatio(1, contentMode: .fit) - .frame(width: 15, height: 15) - .padding(1) } } + .frame(width: 16, height: 16) .foregroundStyle(Color.Codive.main1) Text(item.text) diff --git a/Codive/Shared/DesignSystem/Inputs/CustomTextField1.swift b/Codive/Shared/DesignSystem/Inputs/CustomTextField1.swift index 5e762299..84188b44 100644 --- a/Codive/Shared/DesignSystem/Inputs/CustomTextField1.swift +++ b/Codive/Shared/DesignSystem/Inputs/CustomTextField1.swift @@ -46,18 +46,30 @@ struct CustomTextField1: View { } // TextField - TextField(placeholder, text: $text) - .font(.codive_body1_regular) - .foregroundStyle(Color.Codive.grayscale1) - .padding(.horizontal, 16) - .frame(height: 54) - .background(Color.white) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.Codive.grayscale5, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .tint(Color.Codive.main1) + HStack(spacing: 8) { + TextField(placeholder, text: $text) + .font(.codive_body1_regular) + .foregroundStyle(Color.Codive.grayscale1) + .submitLabel(.done) + + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.Codive.grayscale4) + } + } + } + .padding(.horizontal, 16) + .frame(height: 54) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.Codive.grayscale5, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .tint(Color.Codive.main1) } } } diff --git a/Codive/Shared/DesignSystem/Navigation/CustomNavigationBar.swift b/Codive/Shared/DesignSystem/Navigation/CustomNavigationBar.swift index 163c096c..679ce8c0 100644 --- a/Codive/Shared/DesignSystem/Navigation/CustomNavigationBar.swift +++ b/Codive/Shared/DesignSystem/Navigation/CustomNavigationBar.swift @@ -81,7 +81,7 @@ struct CustomNavigationBar: View { Button(action: onBack) { Image(systemName: "chevron.backward") .font(.system(size: 20, weight: .bold)) - .foregroundStyle(Color.Codive.grayscale3) + .foregroundStyle(Color.Codive.main0) } .frame(width: 44, height: 44) diff --git a/Codive/Shared/Domain/Entities/Cloth.swift b/Codive/Shared/Domain/Entities/Cloth.swift index a5fc160d..c8be344f 100644 --- a/Codive/Shared/Domain/Entities/Cloth.swift +++ b/Codive/Shared/Domain/Entities/Cloth.swift @@ -70,4 +70,15 @@ public enum Season: String, CaseIterable, Identifiable { return "겨울" } } + + /// 현재 월 기준의 시즌 (3-5: 봄, 6-8: 여름, 9-11: 가을, 12-2: 겨울) + public static var current: Season { + let month = Calendar.current.component(.month, from: Date()) + switch month { + case 3, 4, 5: return .spring + case 6, 7, 8: return .summer + case 9, 10, 11: return .fall + default: return .winter + } + } } diff --git a/Codive/Shared/Services/Photo/Data/DataSources/PhotoDataSource.swift b/Codive/Shared/Services/Photo/Data/DataSources/PhotoDataSource.swift index 99da171a..998696ee 100644 --- a/Codive/Shared/Services/Photo/Data/DataSources/PhotoDataSource.swift +++ b/Codive/Shared/Services/Photo/Data/DataSources/PhotoDataSource.swift @@ -7,10 +7,11 @@ import Photos import UIKit +import ImageIO // MARK: - PhotoDataSource final class PhotoDataSource { - + // MARK: - Properties private let imageManager = PHCachingImageManager() @@ -78,21 +79,43 @@ final class PhotoDataSource { } // MARK: - Load Image + + /// ImageIO 다운샘플링으로 이미지 로드 (원본 전체 디코딩 없이 타겟 크기만 생성) func loadImage(for asset: PHAsset, size: CGSize) async -> UIImage? { + guard let imageData = await loadImageData(for: asset) else { return nil } + return downsample(data: imageData, to: size) + } + + private func loadImageData(for asset: PHAsset) async -> Data? { return await withCheckedContinuation { continuation in let options = PHImageRequestOptions() - options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true options.isSynchronous = false - - imageManager.requestImage( + + imageManager.requestImageDataAndOrientation( for: asset, - targetSize: size, - contentMode: .aspectFill, options: options - ) { image, _ in - continuation.resume(returning: image) + ) { data, _, _, _ in + continuation.resume(returning: data) } } } + + private func downsample(data: Data, to size: CGSize) -> UIImage? { + let maxDimension = max(size.width, size.height) + + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension + ] + + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + return UIImage(cgImage: cgImage) + } } diff --git a/Codive/Shared/Services/Photo/Domain/Entities/PhotoAlbum.swift b/Codive/Shared/Services/Photo/Domain/Entities/PhotoAlbum.swift index 99bc07fb..d96aecbf 100644 --- a/Codive/Shared/Services/Photo/Domain/Entities/PhotoAlbum.swift +++ b/Codive/Shared/Services/Photo/Domain/Entities/PhotoAlbum.swift @@ -21,42 +21,26 @@ struct PhotoAlbum { struct PhotoAsset: Identifiable { let id: String let asset: PHAsset - var isSelected: Bool = false - var selectionOrder: Int? } // MARK: - SelectedPhoto Entity struct SelectedPhoto: Identifiable, Equatable, Hashable { let id: String - private(set) var originalImagePath: URL? + var originalImage: UIImage? var croppedImage: UIImage var order: Int var clothTags: [ClothTag] = [] var imageUrl: String? // 수정 모드에서 기존 이미지 URL 저장 var aiImageUrl: String? // AI 누끼 이미지 URL - /// 원본 이미지를 tmp 디스크에 저장하고 경로만 보관 (메모리 절약) - mutating func saveOriginalToDisk(_ image: UIImage) { - let sanitizedId = id.replacingOccurrences(of: "/", with: "_") - let path = FileManager.default.temporaryDirectory - .appendingPathComponent("original_\(sanitizedId).jpg") - if let data = image.jpegData(compressionQuality: 0.9) { - try? data.write(to: path) - originalImagePath = path - } - } - - /// 재크롭 시에만 디스크에서 원본 이미지 로드 + /// 재크롭용 원본 이미지 반환 (없으면 크롭 이미지 사용) func loadOriginalImage() -> UIImage? { - guard let path = originalImagePath, - let data = try? Data(contentsOf: path) else { return nil } - return UIImage(data: data) + return originalImage } - /// tmp 파일 정리 - func cleanupOriginal() { - guard let path = originalImagePath else { return } - try? FileManager.default.removeItem(at: path) + /// 메모리 정리 + mutating func cleanupOriginal() { + originalImage = nil } func hash(into hasher: inout Hasher) { diff --git a/Codive/Shared/Services/Photo/Presentation/Components/CustomCropView.swift b/Codive/Shared/Services/Photo/Presentation/Components/CustomCropView.swift index 27a9b2ea..1f809abf 100644 --- a/Codive/Shared/Services/Photo/Presentation/Components/CustomCropView.swift +++ b/Codive/Shared/Services/Photo/Presentation/Components/CustomCropView.swift @@ -12,14 +12,13 @@ struct CustomCropView: View { let aspectRatio: CGFloat var allowZoomOut: Bool = false - @Environment(\.dismiss) var dismiss - // MARK: - Configuration private let minBoxWidth: CGFloat = 100 private let boxPadding: CGFloat = 20 // MARK: - Callbacks var onComplete: (UIImage) -> Void + var onCancel: () -> Void // MARK: - State @State private var imageScale: CGFloat = 1.0 @@ -85,21 +84,9 @@ struct CustomCropView: View { ) .frame(width: geometry.size.width, height: geometry.size.height) - // Mask & Crop Box + // Dim overlay with crop hole (Path even-odd fill) if isViewInitialized { - Rectangle() - .fill(Color.black.opacity(0.7)) - .mask( - ZStack { - Rectangle().fill(Color.white) - Rectangle() - .fill(Color.black) - .frame(width: currentCropSize.width, height: currentCropSize.height) - .offset(resizeOffset) - .blendMode(.destinationOut) - } - .compositingGroup() - ) + dimOverlay(in: geometry.size) .allowsHitTesting(false) ZStack { @@ -117,16 +104,14 @@ struct CustomCropView: View { .clipped() .coordinateSpace(name: "CROP_AREA") .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.containerSize = geometry.size - initializeLayout(viewSize: geometry.size) - } + self.containerSize = geometry.size + initializeLayout(viewSize: geometry.size) } } // Bottom Toolbar HStack { - Button("취소") { dismiss() } + Button("취소") { onCancel() } .foregroundColor(.white) Spacer() @@ -134,7 +119,6 @@ struct CustomCropView: View { Button("완료") { if let cropped = cropImage() { onComplete(cropped) - dismiss() } } .foregroundColor(.yellow) @@ -149,6 +133,18 @@ struct CustomCropView: View { } } + // MARK: - Dim Overlay (Path even-odd fill, no compositingGroup) + private func dimOverlay(in size: CGSize) -> some View { + let cropX = (size.width - currentCropSize.width) / 2 + resizeOffset.width + let cropY = (size.height - currentCropSize.height) / 2 + resizeOffset.height + + return Path { path in + path.addRect(CGRect(origin: .zero, size: size)) + path.addRect(CGRect(x: cropX, y: cropY, width: currentCropSize.width, height: currentCropSize.height)) + } + .fill(Color.black.opacity(0.7), style: FillStyle(eoFill: true)) + } + // MARK: - Layout Initialization private func initializeLayout(viewSize: CGSize) { let screenW = viewSize.width diff --git a/Codive/Shared/Services/Photo/Presentation/Components/ImageCropView.swift b/Codive/Shared/Services/Photo/Presentation/Components/ImageCropView.swift index be17f5c4..44e07f38 100644 --- a/Codive/Shared/Services/Photo/Presentation/Components/ImageCropView.swift +++ b/Codive/Shared/Services/Photo/Presentation/Components/ImageCropView.swift @@ -22,7 +22,8 @@ struct ImageCropView: View { image: image, aspectRatio: aspectRatio, allowZoomOut: allowZoomOut, - onComplete: onComplete + onComplete: onComplete, + onCancel: onCancel ) } } diff --git a/Codive/Shared/Services/Photo/Presentation/Components/RecordAddView/PhotoGridCell.swift b/Codive/Shared/Services/Photo/Presentation/Components/RecordAddView/PhotoGridCell.swift index af976326..43e8de9d 100644 --- a/Codive/Shared/Services/Photo/Presentation/Components/RecordAddView/PhotoGridCell.swift +++ b/Codive/Shared/Services/Photo/Presentation/Components/RecordAddView/PhotoGridCell.swift @@ -67,7 +67,9 @@ struct PhotoGridCell: View { // MARK: - Methods private func loadImage() async { - image = await viewModel.loadThumbnail(for: asset, size: size) + let scale = UIScreen.main.scale + let thumbnailSize = CGSize(width: size.width * scale, height: size.height * scale) + image = await viewModel.loadThumbnail(for: asset, size: thumbnailSize) if image != nil { withAnimation(.easeOut(duration: 0.2)) { diff --git a/Codive/Shared/Services/Photo/Presentation/EraserEditorView.swift b/Codive/Shared/Services/Photo/Presentation/EraserEditorView.swift index 6b072c82..39d33cba 100644 --- a/Codive/Shared/Services/Photo/Presentation/EraserEditorView.swift +++ b/Codive/Shared/Services/Photo/Presentation/EraserEditorView.swift @@ -29,6 +29,7 @@ struct EraserEditorView: View { @State private var currentPath = Path() @State private var brushSize: CGFloat = 30.0 @State private var canvasSize: CGSize = .zero + @State private var showBackAlert = false // MARK: - Body var body: some View { @@ -36,7 +37,7 @@ struct EraserEditorView: View { CustomNavigationBar( title: "직접 수정하기", onBack: { - navigationRouter.navigateBack() + showBackAlert = true }, rightButton: .text( title: "완료", @@ -151,8 +152,16 @@ struct EraserEditorView: View { } .padding(.bottom, 30) } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .background(Color.white) + .alert("수정을 그만할까요?", isPresented: $showBackAlert) { + Button("취소", role: .cancel) {} + Button("나가기", role: .destructive) { + navigationRouter.navigateBack() + } + } message: { + Text("지금 나가면 수정 내용이 사라져요.") + } } // MARK: - Save Image diff --git a/Codive/Shared/Services/Photo/Presentation/EraserPreviewView.swift b/Codive/Shared/Services/Photo/Presentation/EraserPreviewView.swift index bed07231..9657875b 100644 --- a/Codive/Shared/Services/Photo/Presentation/EraserPreviewView.swift +++ b/Codive/Shared/Services/Photo/Presentation/EraserPreviewView.swift @@ -51,7 +51,7 @@ struct EraserPreviewView: View { Spacer() } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .background(Color.white) } } diff --git a/Codive/Shared/Services/Photo/Presentation/PhotoEditView.swift b/Codive/Shared/Services/Photo/Presentation/PhotoEditView.swift index dc5d1587..75931635 100644 --- a/Codive/Shared/Services/Photo/Presentation/PhotoEditView.swift +++ b/Codive/Shared/Services/Photo/Presentation/PhotoEditView.swift @@ -9,110 +9,111 @@ import SwiftUI // MARK: - PhotoEditView struct PhotoEditView: View { - + // MARK: - Properties @StateObject private var viewModel: PhotoEditViewModel @State private var draggedPhoto: SelectedPhoto? - + // MARK: - Initializer init(viewModel: PhotoEditViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } - + // MARK: - Body var body: some View { - VStack(spacing: 0) { - // Navigation Bar - CustomNavigationBar( - title: TextLiteral.Add.photoEditTitle - ) { - viewModel.dismissView() - } - - // Photo Thumbnails (상단 미리보기) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(Array(viewModel.selectedPhotos.enumerated()), id: \.element.id) { index, photo in - PhotoEditCell( - photo: viewModel.selectedPhotos[index], - isSelected: index == viewModel.currentIndex, - aspectRatio: viewModel.aspectRatio - ) - .id("\(photo.id)-\(photo.croppedImage.hashValue)") - .onTapGesture { - viewModel.currentIndex = index - } - .onDrag { - self.draggedPhoto = photo - return NSItemProvider(object: photo.id as NSString) - } - // swiftlint:disable trailing_closure - .onDrop( - of: [.text], - delegate: PhotoDropDelegate( - photo: photo, - photos: $viewModel.selectedPhotos, - draggedPhoto: $draggedPhoto, - onReorder: { source, destination in - viewModel.reorderPhotos(from: source, to: destination) - } + ZStack { + // Main Content + VStack(spacing: 0) { + // Navigation Bar + CustomNavigationBar( + title: TextLiteral.Add.photoEditTitle + ) { + viewModel.dismissView() + } + + // Photo Thumbnails (상단 미리보기) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Array(viewModel.selectedPhotos.enumerated()), id: \.element.id) { index, photo in + PhotoEditCell( + photo: viewModel.selectedPhotos[index], + isSelected: index == viewModel.currentIndex, + aspectRatio: viewModel.aspectRatio ) - ) - // swiftlint:enable trailing_closure + .id("\(photo.id)-\(photo.croppedImage.hashValue)") + .onTapGesture { + viewModel.currentIndex = index + } + .onDrag { + self.draggedPhoto = photo + return NSItemProvider(object: photo.id as NSString) + } + // swiftlint:disable trailing_closure + .onDrop( + of: [.text], + delegate: PhotoDropDelegate( + photo: photo, + photos: $viewModel.selectedPhotos, + draggedPhoto: $draggedPhoto, + onReorder: { source, destination in + viewModel.reorderPhotos(from: source, to: destination) + } + ) + ) + // swiftlint:enable trailing_closure + } } + .padding(.horizontal, 20) } - .padding(.horizontal, 20) - } - .frame(height: 100) - .padding(.top, 16) - - // Main Image Area (큰 이미지) - ZStack(alignment: .topTrailing) { - if let currentPhoto = viewModel.currentPhoto { - Image(uiImage: currentPhoto.croppedImage) - .resizable() - .aspectRatio(viewModel.aspectRatio, contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } else { - Color.gray.opacity(0.2) - .aspectRatio(viewModel.aspectRatio, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(height: 100) + .padding(.top, 16) + + // Main Image Area (큰 이미지) + ZStack(alignment: .topTrailing) { + if let currentPhoto = viewModel.currentPhoto { + Image(uiImage: currentPhoto.croppedImage) + .resizable() + .aspectRatio(viewModel.aspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Color.gray.opacity(0.2) + .aspectRatio(viewModel.aspectRatio, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + // Crop Button + Button { + viewModel.startEditing() + } label: { + Image("crop_icon") + .resizable() + .frame(width: 30, height: 30) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } + .padding(16) } - - // Crop Button - Button { - viewModel.startEditing() - } label: { - Image("crop_icon") - .resizable() - .frame(width: 30, height: 30) - .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + .padding(.horizontal, 20) + .padding(.top, 16) + + Spacer() + + // Bottom Button + CustomButton( + text: TextLiteral.Add.photoEditComplete, + widthType: .fixed + ) { + viewModel.completeEditing() } - .padding(16) - } - .padding(.horizontal, 20) - .padding(.top, 16) - - Spacer() - - // Bottom Button - CustomButton( - text: TextLiteral.Add.photoEditComplete, - widthType: .fixed - ) { - viewModel.completeEditing() + .padding(.horizontal, 20) + .padding(.top, 40) + .padding(.bottom, 20) } - .padding(.horizontal, 20) - .padding(.top, 40) - .padding(.bottom, 20) - } - .navigationBarHidden(true) - .background(Color.white) - .fullScreenCover(isPresented: $viewModel.isEditingMode) { - if let currentPhoto = viewModel.currentPhoto { - ImageCropView( - image: currentPhoto.loadOriginalImage() ?? currentPhoto.croppedImage, + + // Crop Overlay (인라인, fullScreenCover 제거) + if viewModel.isEditingMode, let currentPhoto = viewModel.currentPhoto { + CustomCropView( + image: currentPhoto.originalImage ?? currentPhoto.croppedImage, aspectRatio: viewModel.aspectRatio, allowZoomOut: viewModel.allowZoomOut, onComplete: { croppedImage in @@ -125,27 +126,27 @@ struct PhotoEditView: View { ) } } + .toolbar(.hidden, for: .navigationBar) + .background(Color.white) .alert(TextLiteral.Add.exitAlertTitle, isPresented: $viewModel.showExitAlert) { Button(TextLiteral.Add.exitAlertLeave, role: .destructive) { viewModel.confirmExit() } } } - + // MARK: - PhotoDropDelegate - // 드래그 앤 드롭으로 사진 순서를 변경하기 위한 델리게이트 - // 상단 썸네일들을 길게 눌러서 드래그하면 순서를 바꿀 수 있음 struct PhotoDropDelegate: DropDelegate { let photo: SelectedPhoto @Binding var photos: [SelectedPhoto] @Binding var draggedPhoto: SelectedPhoto? let onReorder: (IndexSet, Int) -> Void - + func performDrop(info: DropInfo) -> Bool { draggedPhoto = nil return true } - + func dropEntered(info: DropInfo) { guard let draggedPhoto = draggedPhoto, draggedPhoto.id != photo.id, @@ -153,7 +154,7 @@ struct PhotoEditView: View { let toIndex = photos.firstIndex(where: { $0.id == photo.id }) else { return } - + withAnimation(.spring()) { onReorder(IndexSet(integer: fromIndex), toIndex > fromIndex ? toIndex + 1 : toIndex) } diff --git a/Codive/Shared/Services/Photo/Presentation/RecordAddView.swift b/Codive/Shared/Services/Photo/Presentation/RecordAddView.swift index e224a283..7a5800be 100644 --- a/Codive/Shared/Services/Photo/Presentation/RecordAddView.swift +++ b/Codive/Shared/Services/Photo/Presentation/RecordAddView.swift @@ -75,10 +75,11 @@ struct RecordAddView: View { } else { // 실제 사진들 ForEach(viewModel.photos) { photo in + let photoId = photo.id PhotoGridCell( asset: photo.asset, - isSelected: photo.isSelected, - selectionOrder: photo.selectionOrder, + isSelected: viewModel.selectedIds.contains(photoId), + selectionOrder: viewModel.selectionOrder.firstIndex(of: photoId).map { $0 + 1 }, size: CGSize(width: cellSize, height: cellSize), viewModel: viewModel ) @@ -100,7 +101,7 @@ struct RecordAddView: View { LoadingView() } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .enableSwipeBack() .background(Color.white) .sheet(isPresented: $viewModel.isAlbumSheetPresented) { diff --git a/Codive/Shared/Services/Photo/Presentation/ViewModel/PhotoEditViewModel.swift b/Codive/Shared/Services/Photo/Presentation/ViewModel/PhotoEditViewModel.swift index 744044d5..a92ac50a 100644 --- a/Codive/Shared/Services/Photo/Presentation/ViewModel/PhotoEditViewModel.swift +++ b/Codive/Shared/Services/Photo/Presentation/ViewModel/PhotoEditViewModel.swift @@ -126,8 +126,8 @@ final class PhotoEditViewModel: ObservableObject { // MARK: - Private Methods private func cleanupOriginalImages() { - for photo in selectedPhotos { - photo.cleanupOriginal() + for index in selectedPhotos.indices { + selectedPhotos[index].cleanupOriginal() } } } diff --git a/Codive/Shared/Services/Photo/Presentation/ViewModel/RecordAddViewModel.swift b/Codive/Shared/Services/Photo/Presentation/ViewModel/RecordAddViewModel.swift index 30a02d09..7a96def3 100644 --- a/Codive/Shared/Services/Photo/Presentation/ViewModel/RecordAddViewModel.swift +++ b/Codive/Shared/Services/Photo/Presentation/ViewModel/RecordAddViewModel.swift @@ -18,7 +18,8 @@ final class RecordAddViewModel: ObservableObject { @Published var albums: [PhotoAlbum] = [] @Published var selectedAlbum: PhotoAlbum? @Published var photos: [PhotoAsset] = [] - @Published var selectedPhotos: [PhotoAsset] = [] + @Published var selectedIds: Set = [] + @Published var selectionOrder: [String] = [] // 순서 유지용 배열 @Published var isAlbumSheetPresented = false @Published var isCameraPresented = false @Published var authorizationStatus: PHAuthorizationStatus = .notDetermined @@ -40,7 +41,14 @@ final class RecordAddViewModel: ObservableObject { } var isCompleteEnabled: Bool { - !selectedPhotos.isEmpty + !selectedIds.isEmpty + } + + /// 선택된 PhotoAsset 목록 (순서 유지) + var selectedPhotos: [PhotoAsset] { + selectionOrder.compactMap { id in + photos.first { $0.id == id } + } } var selectedAlbumTitle: String { @@ -110,35 +118,12 @@ final class RecordAddViewModel: ObservableObject { } func togglePhotoSelection(_ photo: PhotoAsset) { - if let index = photos.firstIndex(where: { $0.id == photo.id }) { - var updatedPhoto = photos[index] - updatedPhoto.isSelected.toggle() - - if updatedPhoto.isSelected { - let order = selectedPhotos.count + 1 - updatedPhoto.selectionOrder = order - selectedPhotos.append(updatedPhoto) - } else { - selectedPhotos.removeAll { $0.id == photo.id } - updatedPhoto.selectionOrder = nil - reorderSelection() - } - - photos[index] = updatedPhoto - } - } - - private func reorderSelection() { - selectedPhotos = selectedPhotos.enumerated().map { index, photo in - var updatedPhoto = photo - updatedPhoto.selectionOrder = index + 1 - return updatedPhoto - } - - for (index, photo) in selectedPhotos.enumerated() { - if let photoIndex = photos.firstIndex(where: { $0.id == photo.id }) { - photos[photoIndex].selectionOrder = index + 1 - } + if selectedIds.contains(photo.id) { + selectedIds.remove(photo.id) + selectionOrder.removeAll { $0 == photo.id } + } else { + selectedIds.insert(photo.id) + selectionOrder.append(photo.id) } } @@ -153,7 +138,8 @@ final class RecordAddViewModel: ObservableObject { func handleCameraCapture(image: UIImage) { Task { await saveImageToPhotoLibrary(image) - selectedPhotos.removeAll() + selectedIds.removeAll() + selectionOrder.removeAll() await loadAlbums() } } @@ -171,55 +157,73 @@ final class RecordAddViewModel: ObservableObject { } func completeSelection() { - Task { - isCompletingSelection = true - - var selectedPhotoItems: [SelectedPhoto] = [] - - let photosToProcess = selectedPhotos - let targetSize = CGSize(width: 1200, height: 1200) - - for (index, photo) in photosToProcess.enumerated() { - if let image = await fetchPhotosUseCase.loadThumbnail( - for: photo.asset, - size: targetSize - ) { - let croppedImage: UIImage - switch flowType { - case .record: - croppedImage = processImageUseCase.cropTo3_4Ratio(image) - case .cloth: - croppedImage = processImageUseCase.cropTo1_1Ratio(image) + isCompletingSelection = true + + let photosToProcess = selectedPhotos + let targetSize = CGSize(width: 1080, height: 1080) + let currentFlowType = flowType + let cropUseCase = processImageUseCase + let fetchUseCase = fetchPhotosUseCase + + Task.detached(priority: .userInitiated) { + let selectedPhotoItems: [SelectedPhoto] = await withTaskGroup( + of: (Int, SelectedPhoto?).self + ) { group in + for (index, photo) in photosToProcess.enumerated() { + group.addTask { + guard let image = await fetchUseCase.loadThumbnail( + for: photo.asset, + size: targetSize + ) else { + return (index, nil) + } + + let croppedImage: UIImage + switch currentFlowType { + case .record: + croppedImage = cropUseCase.cropTo3_4Ratio(image) + case .cloth: + croppedImage = cropUseCase.cropTo1_1Ratio(image) + } + + let displayReady = await croppedImage.byPreparingForDisplay() ?? croppedImage + let originalReady = await image.byPreparingForDisplay() ?? image + + var selectedPhoto = SelectedPhoto( + id: photo.id, + croppedImage: displayReady, + order: index + 1 + ) + selectedPhoto.originalImage = originalReady + return (index, selectedPhoto) } + } - var selectedPhoto = SelectedPhoto( - id: photo.id, - croppedImage: croppedImage, - order: index + 1 - ) - selectedPhoto.saveOriginalToDisk(image) - selectedPhotoItems.append(selectedPhoto) + var results: [(Int, SelectedPhoto?)] = [] + for await result in group { + results.append(result) } + return results + .sorted { $0.0 < $1.0 } + .compactMap { $0.1 } } - - switch flowType { - case .record: - navigationRouter.navigate(to: .photoEdit(photos: selectedPhotoItems)) - case .cloth: - navigationRouter.navigate(to: .photoEditForCloth(photos: selectedPhotoItems, isAIEnabled: isAIAddEnabled)) + + await MainActor.run { + self.isCompletingSelection = false + + switch self.flowType { + case .record: + self.navigationRouter.navigate(to: .photoEdit(photos: selectedPhotoItems)) + case .cloth: + self.navigationRouter.navigate(to: .photoEditForCloth(photos: selectedPhotoItems, isAIEnabled: self.isAIAddEnabled)) + } } - - resetSelection() - isCompletingSelection = false } } func resetSelection() { - selectedPhotos.removeAll() - for index in photos.indices { - photos[index].isSelected = false - photos[index].selectionOrder = nil - } + selectedIds.removeAll() + selectionOrder.removeAll() } func dismissView() { diff --git a/CodiveTests/Features/Feed/Domain/UseCases/CheckTodayRecordUseCaseTests.swift b/CodiveTests/Features/Feed/Domain/UseCases/CheckTodayRecordUseCaseTests.swift index 81ee8ab8..0f97c28d 100644 --- a/CodiveTests/Features/Feed/Domain/UseCases/CheckTodayRecordUseCaseTests.swift +++ b/CodiveTests/Features/Feed/Domain/UseCases/CheckTodayRecordUseCaseTests.swift @@ -13,14 +13,8 @@ struct CheckTodayRecordUseCaseTests { @Test("오늘 기록이 존재하면 true를 반환한다") func returnsTrueWhenTodayRecordExists() async { - let todayString = DateFormatter.yyyyMMdd.string(from: Date()) - let mockRepository = MockHistoryRepository(items: [ - MonthlyHistoryItem(historyId: 1, firstImageUrl: "https://example.com/1.jpg", historyDate: todayString) - ]) - let useCase = CheckTodayRecordUseCase( - historyRepository: mockRepository, - memberIdProvider: { 1 } - ) + let mockRepository = MockHistoryRepository(todayExists: true) + let useCase = CheckTodayRecordUseCase(historyRepository: mockRepository) let result = await useCase.execute() #expect(result == true) @@ -30,27 +24,8 @@ struct CheckTodayRecordUseCaseTests { @Test("오늘 기록이 없으면 false를 반환한다") func returnsFalseWhenNoTodayRecord() async { - let mockRepository = MockHistoryRepository(items: [ - MonthlyHistoryItem(historyId: 1, firstImageUrl: "https://example.com/1.jpg", historyDate: "2020-01-01") - ]) - let useCase = CheckTodayRecordUseCase( - historyRepository: mockRepository, - memberIdProvider: { 1 } - ) - - let result = await useCase.execute() - #expect(result == false) - } - - // MARK: - Test: memberId가 nil이면 false 반환 - - @Test("memberId가 nil이면 false를 반환한다") - func returnsFalseWhenMemberIdIsNil() async { - let mockRepository = MockHistoryRepository(items: []) - let useCase = CheckTodayRecordUseCase( - historyRepository: mockRepository, - memberIdProvider: { nil } - ) + let mockRepository = MockHistoryRepository(todayExists: false) + let useCase = CheckTodayRecordUseCase(historyRepository: mockRepository) let result = await useCase.execute() #expect(result == false) @@ -61,10 +36,7 @@ struct CheckTodayRecordUseCaseTests { @Test("API 에러 발생 시 false를 반환한다") func returnsFalseOnAPIError() async { let mockRepository = MockHistoryRepositoryWithError() - let useCase = CheckTodayRecordUseCase( - historyRepository: mockRepository, - memberIdProvider: { 1 } - ) + let useCase = CheckTodayRecordUseCase(historyRepository: mockRepository) let result = await useCase.execute() #expect(result == false) @@ -74,14 +46,18 @@ struct CheckTodayRecordUseCaseTests { // MARK: - Mock HistoryRepository private final class MockHistoryRepository: HistoryRepository { - private let items: [MonthlyHistoryItem] + private let todayExists: Bool - init(items: [MonthlyHistoryItem]) { - self.items = items + init(todayExists: Bool) { + self.todayExists = todayExists } func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] { - items + [] + } + + func checkTodayHistoryExists() async throws -> Bool { + todayExists } func deleteHistory(historyId: Int64) async throws {} @@ -92,15 +68,9 @@ private final class MockHistoryRepositoryWithError: HistoryRepository { throw NSError(domain: "TestError", code: -1) } - func deleteHistory(historyId: Int64) async throws {} -} - -// MARK: - DateFormatter Helper + func checkTodayHistoryExists() async throws -> Bool { + throw NSError(domain: "TestError", code: -1) + } -private extension DateFormatter { - static let yyyyMMdd: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() + func deleteHistory(historyId: Int64) async throws {} } diff --git a/Project.swift b/Project.swift index cba7d43b..0939be87 100644 --- a/Project.swift +++ b/Project.swift @@ -6,7 +6,7 @@ import ProjectDescription let crashlyticsScript = TargetScript.post( script: """ if [ "${CONFIGURATION}" = "Release" ]; then - ${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run + "${SRCROOT}/Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/run" fi """, name: "Firebase Crashlytics dSYM Upload", @@ -85,7 +85,7 @@ let project = Project( "UILaunchScreen": [:], "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], "CFBundleDevelopmentRegion": "ko", - "CFBundleLocalizations": ["ko", "en"], + "CFBundleLocalizations": ["ko"], "UIAppFonts": [ "Pretendard-Black.otf", "Pretendard-Bold.otf", @@ -122,23 +122,15 @@ let project = Project( "CFBundleURLSchemes": ["codive"] ] ], + // Background Modes (원격 푸시 알림 수신) + "UIBackgroundModes": ["remote-notification"], + "LSApplicationQueriesSchemes": [ "kakaokompassauth", "storykompassauth", "kakaolink", "kakaotalk-5.9.7" ], - - // App Transport Security - HTTP 도메인 예외 추가 - "NSAppTransportSecurity": [ - "NSExceptionDomains": [ - "prod.clokey.store": [ - "NSIncludesSubdomains": true, - "NSTemporaryExceptionAllowsInsecureHTTPLoads": true - ] - ] - ], - ] ), sources: [ @@ -168,21 +160,25 @@ let project = Project( // Firebase .external(name: "FirebaseAnalytics"), - .external(name: "FirebaseCrashlytics") + .external(name: "FirebaseCrashlytics"), + .external(name: "FirebaseMessaging") ], settings: .settings( base: [ "DEVELOPMENT_TEAM": "BBVZV8T99P", - "CODE_SIGN_STYLE": "Manual" + "CODE_SIGN_STYLE": "Manual", + "OTHER_LDFLAGS": ["$(inherited)", "-ObjC"] ], configurations: [ .debug(name: "Debug", settings: [ "PROVISIONING_PROFILE_SPECIFIER": "match Development com.codive.app", - "CODE_SIGN_IDENTITY": "Apple Development" + "CODE_SIGN_IDENTITY": "Apple Development", + "CODE_SIGN_ENTITLEMENTS": "Codive/Codive.entitlements" ]), .release(name: "Release", settings: [ "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.codive.app", - "CODE_SIGN_IDENTITY": "Apple Distribution" + "CODE_SIGN_IDENTITY": "Apple Distribution", + "CODE_SIGN_ENTITLEMENTS": "Codive/Codive.Release.entitlements" ]) ] ) @@ -199,5 +195,19 @@ let project = Project( .target(name: "Codive") ] ) + ], + schemes: [ + .scheme( + name: "Codive", + buildAction: .buildAction(targets: ["Codive"]), + runAction: .runAction( + configuration: "Debug", + arguments: .arguments( + environmentVariables: [ + "OS_ACTIVITY_MODE": .environmentVariable(value: "disable", isEnabled: true) + ] + ) + ) + ) ] ) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index c4118cbf..2015299a 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -33,7 +33,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "616f6ae0b08a7a51ff3a121bbf64ebc6c235ad1a" + "revision" : "437bdd1ca03827d80ed4da9dd8f869afcd795bfd" } }, { @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "5978979157a5a0521c9c56fd0156aec794caa21c", - "version" : "2.27.2" + "revision" : "32626459f03d9a3622f91cfc001c6dc97cae33a9", + "version" : "2.27.3" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", - "version" : "8.8.0" + "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", + "version" : "8.8.1" } }, { @@ -212,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" + "revision" : "576a65b4ffb8c12ddad4950dc21eea2ef071bec2", + "version" : "1.3.0" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 97eafaad..949e7eec 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -14,7 +14,6 @@ import PackageDescription "KakaoSDKCommon": .framework, "Alamofire": .framework, "Kingfisher": .framework, - "FirebaseAnalytics": .framework, ] ) #endif