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