diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index dddecf84..b645d46f 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -111,7 +111,7 @@ enum TextLiteral { static let situationTravel = "여행" static let situationExercise = "운동" static let situationFestival = "축제" - static let situationWork = "출근복" + static let situationWork = "출근룩" static let situationParty = "파티" // Photo Tag diff --git a/Codive/DIContainer/AppDIContainer.swift b/Codive/DIContainer/AppDIContainer.swift index 5e1b1714..8794f4f2 100644 --- a/Codive/DIContainer/AppDIContainer.swift +++ b/Codive/DIContainer/AppDIContainer.swift @@ -66,4 +66,8 @@ final class AppDIContainer { func makeCommentDIContainer() -> CommentDIContainer { return CommentDIContainer(navigationRouter: navigationRouter) } + + func makeProfileDIContainer() -> ProfileDIContainer { + return ProfileDIContainer(navigationRouter: navigationRouter) + } } diff --git a/Codive/DIContainer/CommentDIContainer.swift b/Codive/DIContainer/CommentDIContainer.swift index e120d433..0797f010 100644 --- a/Codive/DIContainer/CommentDIContainer.swift +++ b/Codive/DIContainer/CommentDIContainer.swift @@ -6,10 +6,11 @@ // import Foundation +import CodiveAPI @MainActor final class CommentDIContainer { - + // MARK: - Properties let navigationRouter: NavigationRouter lazy var commentViewFactory = CommentViewFactory(commentDIContainer: self) @@ -22,8 +23,7 @@ final class CommentDIContainer { // MARK: - DataSources private lazy var commentDataSource: CommentDataSource = { - // 나중에 실제 API가 구현되면 이 부분만 DefaultCommentDataSource()로 교체 - return MockCommentDataSource() + return DefaultCommentDataSource() }() // MARK: - Repositories @@ -42,13 +42,23 @@ final class CommentDIContainer { return DefaultPostCommentUseCase(commentRepository: commentRepository) } + func makeFetchRepliesUseCase() -> FetchRepliesUseCase { + return DefaultFetchRepliesUseCase(commentRepository: commentRepository) + } + + func makePostReplyUseCase() -> PostReplyUseCase { + return DefaultPostReplyUseCase(commentRepository: commentRepository) + } + // MARK: - ViewModels func makeCommentViewModel(feedId: Int) -> CommentViewModel { return CommentViewModel( feedId: feedId, fetchCommentsUseCase: makeFetchCommentsUseCase(), - postCommentUseCase: makePostCommentUseCase() + postCommentUseCase: makePostCommentUseCase(), + fetchRepliesUseCase: makeFetchRepliesUseCase(), + postReplyUseCase: makePostReplyUseCase() ) } diff --git a/Codive/DIContainer/FeedDIContainer.swift b/Codive/DIContainer/FeedDIContainer.swift index d7ba0ca0..9316806d 100644 --- a/Codive/DIContainer/FeedDIContainer.swift +++ b/Codive/DIContainer/FeedDIContainer.swift @@ -12,7 +12,9 @@ final class FeedDIContainer { // MARK: - Properties let navigationRouter: NavigationRouter - lazy var feedViewFactory = FeedViewFactory(feedDIContainer: self) + lazy var feedViewFactory = FeedViewFactory(feedDIContainer: self, navigationRouter: navigationRouter) + lazy var commentDIContainer = CommentDIContainer(navigationRouter: navigationRouter) + lazy var profileDIContainer = ProfileDIContainer(navigationRouter: navigationRouter) // MARK: - Initializer init(navigationRouter: NavigationRouter) { @@ -26,7 +28,7 @@ final class FeedDIContainer { }() private lazy var feedDataSource: FeedDataSource = { - return MockFeedDataSource() + return DefaultFeedDataSource() }() // MARK: - Repositories @@ -36,7 +38,10 @@ final class FeedDIContainer { }() private lazy var feedRepository: FeedRepository = { - return FeedRepositoryImpl(dataSource: feedDataSource) + return FeedRepositoryImpl( + dataSource: feedDataSource, + historyAPIService: HistoryAPIService() + ) }() // MARK: - UseCases @@ -57,13 +62,21 @@ final class FeedDIContainer { return DefaultFetchFeedLikersUseCase(feedRepository: feedRepository) } + func makeToggleLikeUseCase() -> ToggleLikeUseCase { + return DefaultToggleLikeUseCase(feedRepository: feedRepository) + } + + func makeFetchClothTagsUseCase() -> FetchClothTagsUseCase { + return DefaultFetchClothTagsUseCase(feedRepository: feedRepository) + } + // MARK: - ViewModels func makeFeedViewModel() -> FeedViewModel { return FeedViewModel( navigationRouter: navigationRouter, fetchFeedsUseCase: makeFetchFeedsUseCase(), - feedRepository: feedRepository + toggleLikeUseCase: makeToggleLikeUseCase() ) } @@ -72,7 +85,8 @@ final class FeedDIContainer { feedId: feedId, fetchFeedDetailUseCase: makeFetchFeedDetailUseCase(), fetchLikersUseCase: makeFetchFeedLikersUseCase(), - feedRepository: feedRepository, + toggleLikeUseCase: makeToggleLikeUseCase(), + fetchClothTagsUseCase: makeFetchClothTagsUseCase(), navigationRouter: navigationRouter ) } @@ -82,7 +96,8 @@ final class FeedDIContainer { func makeFeedDetailView(feedId: Int) -> FeedDetailView { return FeedDetailView( viewModel: makeFeedDetailViewModel(feedId: feedId), - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + commentDIContainer: commentDIContainer ) } } @@ -95,13 +110,16 @@ extension FeedDIContainer { repository: FeedRepository, navigationRouter: NavigationRouter ) -> FeedDetailViewModel { - let useCase = DefaultFetchFeedDetailUseCase(repository: repository) - let likersUseCase = DefaultFetchFeedLikersUseCase(feedRepository: repository) + let detailUseCase = DefaultFetchFeedDetailUseCase(repository: repository) + let likersUseCase = DefaultFetchFeedLikersUseCase(feedRepository: repository) + let toggleLikeUseCase = DefaultToggleLikeUseCase(feedRepository: repository) + let clothTagsUseCase = DefaultFetchClothTagsUseCase(feedRepository: repository) return FeedDetailViewModel( feedId: feedId, - fetchFeedDetailUseCase: useCase, + fetchFeedDetailUseCase: detailUseCase, fetchLikersUseCase: likersUseCase, - feedRepository: repository, + toggleLikeUseCase: toggleLikeUseCase, + fetchClothTagsUseCase: clothTagsUseCase, navigationRouter: navigationRouter ) } diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift new file mode 100644 index 00000000..5db9497c --- /dev/null +++ b/Codive/DIContainer/ProfileDIContainer.swift @@ -0,0 +1,95 @@ +// +// ProfileDIContainer.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +@MainActor +final class ProfileDIContainer { + + // MARK: - Properties + let navigationRouter: NavigationRouter + + // MARK: - Initializer + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + } + + // MARK: - API Service + private lazy var profileAPIService: ProfileAPIServiceProtocol = { + return ProfileAPIService() + }() + + // MARK: - Data Sources + private lazy var profileDataSource: ProfileDataSourceProtocol = { + return ProfileDataSource(apiService: profileAPIService) + }() + + // MARK: - Repositories + private lazy var profileRepository: ProfileRepository = { + return ProfileRepositoryImpl(dataSource: profileDataSource) + }() + + // MARK: - UseCases + func makeFetchMyProfileUseCase() -> FetchMyProfileUseCase { + return DefaultFetchMyProfileUseCase(repository: profileRepository) + } + + func makeFetchFollowsUseCase() -> FetchFollowsUseCase { + return DefaultFetchFollowsUseCase(repository: profileRepository) + } + + func makeUpdateProfileUseCase() -> UpdateProfileUseCase { + return DefaultUpdateProfileUseCase(repository: profileRepository) + } + + // MARK: - ViewModels + func makeProfileViewModel() -> ProfileViewModel { + return ProfileViewModel( + navigationRouter: navigationRouter, + fetchMyProfileUseCase: makeFetchMyProfileUseCase() + ) + } + + func makeFollowListViewModel(mode: FollowListMode, memberId: Int) -> FollowListViewModel { + return FollowListViewModel( + mode: mode, + memberId: memberId, + fetchFollowsUseCase: makeFetchFollowsUseCase() + ) + } + + func makeProfileSettingViewModel() -> ProfileSettingViewModel { + return ProfileSettingViewModel( + navigationRouter: navigationRouter, + updateProfileUseCase: makeUpdateProfileUseCase(), + profileRepository: profileRepository + ) + } + + func makeOtherProfileViewModel() -> OtherProfileViewModel { + return OtherProfileViewModel(navigationRouter: navigationRouter) + } + + // MARK: - Views + func makeProfileView() -> ProfileView { + return ProfileView(viewModel: makeProfileViewModel(), navigationRouter: navigationRouter) + } + + func makeFollowListView(mode: FollowListMode, memberId: Int) -> FollowListView { + return FollowListView( + viewModel: makeFollowListViewModel(mode: mode, memberId: memberId), + navigationRouter: navigationRouter + ) + } + + func makeProfileSettingView() -> ProfileSettingView { + return ProfileSettingView( + viewModel: makeProfileSettingViewModel(), + navigationRouter: navigationRouter + ) + } +} diff --git a/Codive/DIContainer/SearchDIContainer.swift b/Codive/DIContainer/SearchDIContainer.swift index 70fb9427..6b22a155 100644 --- a/Codive/DIContainer/SearchDIContainer.swift +++ b/Codive/DIContainer/SearchDIContainer.swift @@ -13,8 +13,10 @@ final class SearchDIContainer { let navigationRouter: NavigationRouter lazy var searchViewFactory = SearchViewFactory(searchDIContainer: self) - lazy var searchDataSource = SearchDataSource() - + lazy var searchAPIService: SearchAPIServiceProtocol = SearchAPIService() + + lazy var searchDataSource = SearchDataSource(apiService: searchAPIService) + lazy var searchRepository: SearchRepository = SearchRepositoryImpl(datasource: searchDataSource) lazy var searchUseCase = SearchUseCase(repository: searchRepository) diff --git a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift index e987e7c7..ed9a5000 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift @@ -67,10 +67,9 @@ final class SplashViewModel: ObservableObject { private func checkAutoLogin() async { // 임시 자동 로그인 해제 (토큰이 있어도 로그인 화면으로 이동) - appRouter.finishSplash() - return +// appRouter.finishSplash() +// return - /* // 1. 키체인에 토큰이 있는지 확인 guard tokenService.hasValidTokens() else { appRouter.finishSplash() @@ -91,7 +90,6 @@ final class SplashViewModel: ObservableObject { // 4. 토큰 재발급 await reissueTokens() - */ } private func reissueTokens() async { diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 4c52dbc3..5aba1cbc 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -58,6 +58,7 @@ struct ClothDetailResult { let name: String? let brand: String? let clothUrl: String? + let seasons: [Season] } struct ClothUpdateAPIRequest { @@ -220,13 +221,18 @@ extension ClothAPIService { throw ClothAPIError.serverError(statusCode: 0, message: "result가 nil입니다") } + let seasons = result.seasons?.compactMap { seasonString -> Season? in + Season(rawValue: seasonString.rawValue) + } ?? [] + return ClothDetailResult( clothImageUrl: result.clothImageUrl ?? "", parentCategory: result.parentCategory, category: result.category, name: result.name, brand: result.brand, - clothUrl: result.clothUrl + clothUrl: result.clothUrl, + seasons: seasons ) case .undocumented(statusCode: let code, _): diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index bf19cc18..2da42e8a 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -56,31 +56,25 @@ final class DefaultClothDataSource: ClothDataSource { self.apiService = apiService } - // MARK: - Mock Data (TODO: API 연결 후 제거) - - private let mockClothItems: [ProductItem] = [ - ProductItem(id: 1, imageName: "sample1", isTodayCloth: true, brand: "Nike", name: "에어포스 1"), - ProductItem(id: 2, imageName: "sample2", isTodayCloth: true, brand: "Adidas", name: "후디"), - ProductItem(id: 3, imageName: "sample3", isTodayCloth: true, brand: nil, name: "검은 모자"), - ProductItem(id: 4, imageName: "sample4", isTodayCloth: false, brand: "Uniqlo", name: "오버핏 티셔츠"), - ProductItem(id: 5, imageName: "sample5", isTodayCloth: false, brand: "Zara", name: "슬랙스"), - ProductItem(id: 6, imageName: "sample6", isTodayCloth: false, brand: nil, name: nil) - ] - // MARK: - Methods - + func fetchClothItems(category: String?) async throws -> [ProductItem] { - // TODO: 실제 API 호출로 대체 + // 전체 옷 목록 조회 (페이지네이션 없이 전체) + let result = try await apiService.fetchClothes( + lastClothId: nil, + size: 100, + categoryId: nil, + seasons: [] + ) - // 카테고리 필터링 (전체면 전부 반환) - if let category = category, category != "전체" { - return mockClothItems.filter { _ in - // TODO: ProductItem에 category 필드 추가 후 필터링 - return true - } + return result.clothes.map { item in + ProductItem( + id: Int(item.clothId), + imageUrl: item.imageUrl, + brand: item.brand, + name: item.name + ) } - - return mockClothItems } /// 옷 저장 (전체 흐름: Presigned URL → S3 업로드 → 옷 생성) diff --git a/Codive/Features/Closet/Domain/Entities/ProductItem.swift b/Codive/Features/Closet/Domain/Entities/ProductItem.swift index 975e85cb..43a1a4f6 100644 --- a/Codive/Features/Closet/Domain/Entities/ProductItem.swift +++ b/Codive/Features/Closet/Domain/Entities/ProductItem.swift @@ -10,8 +10,25 @@ import Foundation // MARK: - Product Item Model struct ProductItem: Identifiable { let id: Int - let imageName: String + let imageName: String? + let imageUrl: String? let isTodayCloth: Bool let brand: String? let name: String? + + init( + id: Int, + imageName: String? = nil, + imageUrl: String? = nil, + isTodayCloth: Bool = false, + brand: String? = nil, + name: String? = nil + ) { + self.id = id + self.imageName = imageName + self.imageUrl = imageUrl + self.isTodayCloth = isTodayCloth + self.brand = brand + self.name = name + } } diff --git a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift index 40b22b1d..f1509a80 100644 --- a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift +++ b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift @@ -240,9 +240,11 @@ struct CustomAIRecommendationView: View { } private var thumbnailImagesView: some View { - HStack(spacing: 8) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - thumbnailButton(for: item, at: index) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + thumbnailButton(for: item, at: index) + } } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift index c1987b2d..713957e3 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift @@ -55,12 +55,15 @@ final class ClothDetailViewModel: ObservableObject { } var seasonText: String { - if cloth.seasons.isEmpty { + // API 상세 응답이 있으면 우선 사용 + let seasons: [Season] = detailData?.seasons ?? Array(cloth.seasons) + + if seasons.isEmpty { return "계절 없음" } let orderedSeasons: [Season] = [.spring, .summer, .fall, .winter] - let selectedSeasons = orderedSeasons.filter { cloth.seasons.contains($0) } + let selectedSeasons = orderedSeasons.filter { seasons.contains($0) } return selectedSeasons.map { $0.displayName }.joined(separator: ", ") } diff --git a/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift b/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift index ecb70e2c..e2eeee65 100644 --- a/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift +++ b/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift @@ -6,28 +6,201 @@ // import Foundation +import CodiveAPI // MARK: - Protocol protocol CommentDataSource { func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) func postComment(feedId: Int, content: String) async throws -> Comment + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment +} + +// MARK: - Default Implementation (CodiveAPI) +final class DefaultCommentDataSource: CommentDataSource { + private let apiClient: Client + private let jsonDecoder: JSONDecoder + + init() { + self.apiClient = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + init(apiClient: Client) { + self.apiClient = apiClient + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { + let response = try await apiClient.Comment_getComments( + query: Operations.Comment_getComments.Input.Query( + historyId: Int64(feedId), + lastCommentId: nil, + size: 10, + direction: .DESC + ) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseCommentListResponse.self, + from: data + ) + + let comments = (apiResponse.result?.content ?? []).map { Comment.from(apiResponse: $0) } + let hasNext = !(apiResponse.result?.isLast ?? true) + + return (comments: comments, hasNext: hasNext) + default: + if case .undocumented(let statusCode, let payload) = response { + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let responseBody = String(data: data, encoding: .utf8) { + print("fetchComments error response [\(statusCode)]: \(responseBody)") + } + } + } + print("fetchComments response: \(response)") + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch comments"]) + } + } + + func postComment(feedId: Int, content: String) async throws -> Comment { + let body = Components.Schemas.CommentCreateRequest( + historyId: Int64(feedId), + content: content + ) + + let response = try await apiClient.Comment_createComment( + body: .json(body) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseCommentCreateResponse.self, + from: data + ) + + guard let commentId = apiResponse.result?.commentId else { + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + } + + // 새로운 댓글 객체 생성 (추가 정보는 필요하면 별도로 조회) + let currentUser = User(id: "", nickname: "현재 사용자", profileImageUrl: nil) + let newComment = Comment( + id: Int(commentId), + content: content, + author: currentUser, + isMine: true, + hasReplies: false, + replies: [] + ) + + return newComment + default: + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to post comment"]) + } + } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + let response = try await apiClient.Comment_getReplies( + path: Operations.Comment_getReplies.Input.Path(commentId: Int64(commentId)), + query: Operations.Comment_getReplies.Input.Query( + lastReplyId: nil, + size: 10, + direction: .DESC + ) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseReplyListResponse.self, + from: data + ) + + let replies = (apiResponse.result?.content ?? []).map { Comment.from(apiResponse: $0) } + let hasNext = !(apiResponse.result?.isLast ?? true) + + return (replies: replies, hasNext: hasNext) + default: + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch replies"]) + } + } + + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + // 대댓글 작성용 요청 + let body = Components.Schemas.CommentCreateRequest( + historyId: Int64(feedId), + content: content + ) + + let response = try await apiClient.Comment_createReply( + path: Operations.Comment_createReply.Input.Path(commentId: Int64(commentId)), + body: .json(body) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseCommentCreateResponse.self, + from: data + ) + + guard let replyId = apiResponse.result?.commentId else { + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + } + + // 새로운 대댓글 객체 생성 + let currentUser = User(id: "", nickname: "현재 사용자", profileImageUrl: nil) + let newReply = Comment( + id: Int(replyId), + content: content, + author: currentUser, + isMine: true, + hasReplies: false, + replies: [] + ) + + return newReply + default: + print("postReply response: \(response)") + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to post reply"]) + } + } } // MARK: - Mock Implementation final class MockCommentDataSource: CommentDataSource { func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { try await Task.sleep(nanoseconds: 500_000_000) - + if page == 0 { return (comments: CommentMockData.comments, hasNext: true) } else { return (comments: [], hasNext: false) } } - + func postComment(feedId: Int, content: String) async throws -> Comment { try await Task.sleep(nanoseconds: 300_000_000) - + let newComment = Comment( id: Int.random(in: 100...999), content: content, @@ -36,4 +209,21 @@ final class MockCommentDataSource: CommentDataSource { ) return newComment } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + try await Task.sleep(nanoseconds: 500_000_000) + return (replies: [], hasNext: false) + } + + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + try await Task.sleep(nanoseconds: 300_000_000) + + let newReply = Comment( + id: Int.random(in: 100...999), + content: content, + author: CommentMockData.users[2], + isMine: true + ) + return newReply + } } diff --git a/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift b/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift index 3f88cb94..fa94ae9c 100644 --- a/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift +++ b/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift @@ -9,11 +9,11 @@ import Foundation import Combine final class MockCommentRepository: CommentRepository { - + func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { // 0.2초 딜레이 try await Task.sleep(nanoseconds: 200_000_000) - + // 페이지가 0일 때만 가짜 데이터를 반환하고, 그 이후 페이지는 없다고 가정 if page == 0 { return (comments: CommentMockData.comments, hasNext: true) @@ -21,11 +21,11 @@ final class MockCommentRepository: CommentRepository { return (comments: [], hasNext: false) } } - + @discardableResult func postComment(feedId: Int, content: String) async throws -> Comment { try await Task.sleep(nanoseconds: 300_000_000) - + let newComment = Comment( id: Int.random(in: 100...999), content: content, @@ -35,4 +35,36 @@ final class MockCommentRepository: CommentRepository { ) return newComment } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + try await Task.sleep(nanoseconds: 200_000_000) + + if page == 0 { + return (replies: CommentMockData.comments.suffix(2).map { comment in + Comment( + id: comment.id + 1000, + content: comment.content, + author: comment.author, + isMine: false, + hasReplies: false + ) + }, hasNext: false) + } else { + return (replies: [], hasNext: false) + } + } + + @discardableResult + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + try await Task.sleep(nanoseconds: 300_000_000) + + let newReply = Comment( + id: Int.random(in: 1000...9999), + content: content, + author: CommentMockData.users[2], + isMine: true, + hasReplies: false + ) + return newReply + } } diff --git a/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift b/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift index d8591e67..3afb90d1 100644 --- a/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift +++ b/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift @@ -25,4 +25,12 @@ final class CommentRepositoryImpl: CommentRepository { func postComment(feedId: Int, content: String) async throws -> Comment { return try await dataSource.postComment(feedId: feedId, content: content) } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + return try await dataSource.fetchReplies(commentId: commentId, page: page) + } + + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + return try await dataSource.postReply(feedId: feedId, commentId: commentId, content: content) + } } diff --git a/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift b/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift index 7b1096c8..addfefcc 100644 --- a/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift +++ b/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift @@ -8,6 +8,7 @@ import Foundation /// 댓글 도메인에 대한 데이터 소스 액세스를 정의하는 프로토콜임. + public protocol CommentRepository { /// 특정 피드에 달린 댓글 목록을 가져옴. /// - Parameters: @@ -15,7 +16,7 @@ public protocol CommentRepository { /// - page: 페이지네이션을 위한 페이지 번호 /// - Returns: `Comment` 배열과 다음 페이지 존재 여부를 포함하는 튜플 func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) - + /// 새로운 댓글을 작성함. /// - Parameters: /// - feedId: 댓글을 작성할 피드의 ID @@ -23,4 +24,20 @@ public protocol CommentRepository { /// - Returns: 생성된 `Comment` 객체 @discardableResult func postComment(feedId: Int, content: String) async throws -> Comment + + /// 특정 댓글의 대댓글 목록을 가져옴. + /// - Parameters: + /// - commentId: 대댓글을 조회할 댓글의 ID + /// - page: 페이지네이션을 위한 페이지 번호 + /// - Returns: `Comment` 배열과 다음 페이지 존재 여부를 포함하는 튜플 + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) + + /// 새로운 대댓글을 작성함. + /// - Parameters: + /// - feedId: 피드 ID + /// - commentId: 대댓글을 작성할 댓글의 ID + /// - content: 대댓글 내용 + /// - Returns: 생성된 `Comment` 객체 + @discardableResult + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment } diff --git a/Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift b/Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift new file mode 100644 index 00000000..74048fb6 --- /dev/null +++ b/Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift @@ -0,0 +1,28 @@ +// +// FetchRepliesUseCase.swift +// Codive +// +// Created by 황상환 on 2025/12/22. +// + +import Foundation + +protocol FetchRepliesUseCase { + func execute(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) +} + +final class DefaultFetchRepliesUseCase: FetchRepliesUseCase { + + // MARK: - Properties + private let commentRepository: CommentRepository + + // MARK: - Initializer + init(commentRepository: CommentRepository) { + self.commentRepository = commentRepository + } + + // MARK: - FetchRepliesUseCase + func execute(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + return try await commentRepository.fetchReplies(commentId: commentId, page: page) + } +} diff --git a/Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift b/Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift new file mode 100644 index 00000000..4a1726ae --- /dev/null +++ b/Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift @@ -0,0 +1,30 @@ +// +// PostReplyUseCase.swift +// Codive +// +// Created by 황상환 on 2025/12/22. +// + +import Foundation + +protocol PostReplyUseCase { + @discardableResult + func execute(feedId: Int, commentId: Int, content: String) async throws -> Comment +} + +final class DefaultPostReplyUseCase: PostReplyUseCase { + + // MARK: - Properties + private let commentRepository: CommentRepository + + // MARK: - Initializer + init(commentRepository: CommentRepository) { + self.commentRepository = commentRepository + } + + // MARK: - PostReplyUseCase + @discardableResult + func execute(feedId: Int, commentId: Int, content: String) async throws -> Comment { + return try await commentRepository.postReply(feedId: feedId, commentId: commentId, content: content) + } +} diff --git a/Codive/Features/Comment/Presentation/View/CommentView.swift b/Codive/Features/Comment/Presentation/View/CommentView.swift index 2ec93b49..dc5ff3ed 100644 --- a/Codive/Features/Comment/Presentation/View/CommentView.swift +++ b/Codive/Features/Comment/Presentation/View/CommentView.swift @@ -40,7 +40,7 @@ struct CommentView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { ForEach(viewModel.comments) { comment in - CommentRow(comment: comment) + CommentRow(comment: comment, viewModel: viewModel) } if viewModel.isLoading { ProgressView() @@ -59,15 +59,57 @@ struct CommentView: View { // MARK: - Comment Input Area VStack(spacing: 0) { Divider().overlay(Color.Codive.grayscale6) - HStack(alignment: .center, spacing: 12) { - TextField(TextLiteral.Comment.placeholder, text: $viewModel.currentCommentText) - .padding(.horizontal, 15) - .frame(height: 40) - .background(Color.Codive.main6) + + // 대댓글 입력 중이면 표시 + if let replyingCommentId = viewModel.replyingToCommentId, + let replyingComment = viewModel.comments.first(where: { $0.id == replyingCommentId }) { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("@\(replyingComment.author.nickname)") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.main0) + Text(replyingComment.content) + .font(.codive_body3_regular) + .foregroundStyle(Color.Codive.grayscale2) + .lineLimit(1) + } + Spacer() + Button(action: { + viewModel.cancelReply() + }, label: { + Image(systemName: "xmark") + .font(.system(size: 12)) + .foregroundStyle(Color.Codive.grayscale4) + }) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.Codive.grayscale6) .clipShape(RoundedRectangle(cornerRadius: 8)) - .font(.codive_body2_regular) + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + } + + // 댓글/대댓글 입력 필드 + HStack(alignment: .center, spacing: 12) { + TextField( + viewModel.replyingToCommentId != nil ? "대댓글 입력..." : TextLiteral.Comment.placeholder, + text: viewModel.replyingToCommentId != nil ? $viewModel.currentReplyText : $viewModel.currentCommentText + ) + .padding(.horizontal, 15) + .frame(height: 40) + .background(Color.Codive.main6) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .font(.codive_body2_regular) + Button(action: { - viewModel.postComment() + if viewModel.replyingToCommentId != nil { + viewModel.postReply() + } else { + viewModel.postComment() + } }, label: { Image("comment_enter") .foregroundStyle(.white) @@ -75,7 +117,11 @@ struct CommentView: View { .background(Color.Codive.main0) .clipShape(Circle()) }) - .disabled(viewModel.currentCommentText.isEmpty) + .disabled( + viewModel.replyingToCommentId != nil + ? viewModel.currentReplyText.isEmpty + : viewModel.currentCommentText.isEmpty + ) } .padding(.horizontal, 20) .padding(.bottom, 40) @@ -99,7 +145,8 @@ struct CommentView: View { struct CommentRow: View { let comment: Comment var isReply: Bool = false - + @ObservedObject var viewModel: CommentViewModel + @State private var isExpanded: Bool = false var body: some View { @@ -110,16 +157,26 @@ struct CommentRow: View { AsyncImage(url: URL(string: comment.author.profileImageUrl ?? "")) { image in image.resizable().aspectRatio(contentMode: .fill) } placeholder: { - Circle().fill(Color.Codive.grayscale5) + Image("Profile") + .resizable() + .aspectRatio(contentMode: .fill) } .frame(width: isReply ? 28 : 36, height: isReply ? 28 : 36) .clipShape(Circle()) VStack(alignment: .leading, spacing: 4) { // 닉네임 - Text(comment.author.nickname) - .font(.codive_body2_medium) - .foregroundStyle(Color.Codive.grayscale1) + HStack(spacing: 4) { + Text(comment.author.nickname) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + + if comment.isMine { + Text("(작성자)") + .font(.codive_body3_regular) + .foregroundStyle(Color.Codive.main0) + } + } // 댓글내용 Text(comment.content) @@ -128,7 +185,9 @@ struct CommentRow: View { .fixedSize(horizontal: false, vertical: true) .lineSpacing(4) - Button(action: {}, label: { + Button(action: { + viewModel.setReplyingTo(commentId: comment.id) + }, label: { Text(TextLiteral.Comment.addReply) .font(.codive_body3_regular) .foregroundStyle(Color.Codive.grayscale4) @@ -136,13 +195,24 @@ struct CommentRow: View { .padding(.top, 4) // MARK: 답글 더보기/숨기기 버튼 - if comment.hasReplies, let replies = comment.replies, !replies.isEmpty { + if comment.hasReplies { Button(action: { - withAnimation(.easeOut(duration: 0.2)) { isExpanded.toggle() } + withAnimation(.easeOut(duration: 0.2)) { + isExpanded.toggle() + if isExpanded && (comment.replies?.isEmpty ?? true) { + viewModel.fetchReplies(for: comment.id) + } + } }, label: { - Text(isExpanded ? TextLiteral.Comment.hideReplies : TextLiteral.Comment.repliesCount(replies.count)) - .font(.codive_body2_regular) - .foregroundStyle(Color.Codive.grayscale4) + if let replies = comment.replies, !replies.isEmpty { + Text(isExpanded ? TextLiteral.Comment.hideReplies : TextLiteral.Comment.repliesCount(replies.count)) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + } else { + Text(TextLiteral.Comment.repliesCount(1)) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + } }) .padding(.top, 8) } @@ -160,7 +230,7 @@ struct CommentRow: View { if isExpanded, let replies = comment.replies { VStack(alignment: .leading, spacing: 20) { ForEach(replies) { reply in - CommentRow(comment: reply, isReply: true) + CommentRow(comment: reply, isReply: true, viewModel: viewModel) } } .padding(.top, 10) @@ -171,18 +241,22 @@ struct CommentRow: View { // MARK: - Preview struct CommentView_Previews: PreviewProvider { - + static var previews: some View { let mockRepository = MockCommentRepository() let fetchUseCase = DefaultFetchCommentsUseCase(commentRepository: mockRepository) let postUseCase = DefaultPostCommentUseCase(commentRepository: mockRepository) - + let fetchRepliesUseCase = DefaultFetchRepliesUseCase(commentRepository: mockRepository) + let postReplyUseCase = DefaultPostReplyUseCase(commentRepository: mockRepository) + let viewModel = CommentViewModel( feedId: 1, fetchCommentsUseCase: fetchUseCase, - postCommentUseCase: postUseCase + postCommentUseCase: postUseCase, + fetchRepliesUseCase: fetchRepliesUseCase, + postReplyUseCase: postReplyUseCase ) - + return CommentView(viewModel: viewModel) .previewDisplayName("댓글과 답글") } diff --git a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift index 65ae81d6..8bbc475e 100644 --- a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift +++ b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift @@ -10,37 +10,48 @@ import Combine @MainActor final class CommentViewModel: ObservableObject { - + // MARK: - Properties - + // 댓글 목록 @Published var comments: [Comment] = [] - + // 현재 댓글 입력 텍스트 @Published var currentCommentText: String = "" - + // 댓글 로딩 상태 @Published var isLoading: Bool = false - + // 다음 페이지 존재 여부 @Published var hasNextPage: Bool = true - + + // 대댓글 관련 상태 + @Published var replyingToCommentId: Int? + @Published var currentReplyText: String = "" + @Published var isReplyLoading: Bool = false + private var cancellables = Set() - + private let feedId: Int private let fetchCommentsUseCase: FetchCommentsUseCase private let postCommentUseCase: PostCommentUseCase + private let fetchRepliesUseCase: FetchRepliesUseCase + private let postReplyUseCase: PostReplyUseCase // MARK: - Initializer - + init( feedId: Int, fetchCommentsUseCase: FetchCommentsUseCase, - postCommentUseCase: PostCommentUseCase + postCommentUseCase: PostCommentUseCase, + fetchRepliesUseCase: FetchRepliesUseCase, + postReplyUseCase: PostReplyUseCase ) { self.feedId = feedId self.fetchCommentsUseCase = fetchCommentsUseCase self.postCommentUseCase = postCommentUseCase + self.fetchRepliesUseCase = fetchRepliesUseCase + self.postReplyUseCase = postReplyUseCase } // MARK: - Public Methods @@ -48,7 +59,7 @@ final class CommentViewModel: ObservableObject { func fetchFirstPage() { guard !isLoading, hasNextPage else { return } isLoading = true - + Task { do { let result = try await fetchCommentsUseCase.execute(feedId: feedId, page: 0) @@ -61,7 +72,22 @@ final class CommentViewModel: ObservableObject { self.isLoading = false } } - + + func reloadComments() { + isLoading = true + + Task { + do { + let result = try await fetchCommentsUseCase.execute(feedId: feedId, page: 0) + self.comments = result.comments + self.hasNextPage = result.hasNext + } catch { + print("Error reloading comments: \(error)") + } + self.isLoading = false + } + } + func fetchNextPage() { // TODO: 다음 페이지 로딩 구현 (페이지네이션) } @@ -69,16 +95,85 @@ final class CommentViewModel: ObservableObject { func postComment() { guard !currentCommentText.isEmpty else { return } let content = currentCommentText - + Task { do { - let newComment = try await postCommentUseCase.execute(feedId: feedId, content: content) - self.comments.insert(newComment, at: 0) + _ = try await postCommentUseCase.execute(feedId: feedId, content: content) self.currentCommentText = "" + // 댓글 작성 후 목록 다시 로드 (실제 사용자 정보 반영) + self.reloadComments() } catch { // TODO: 에러 처리 print("Error posting comment: \(error)") } } } + + // MARK: - Reply Methods + + func setReplyingTo(commentId: Int) { + replyingToCommentId = commentId + currentReplyText = "" + } + + func cancelReply() { + replyingToCommentId = nil + currentReplyText = "" + } + + func fetchReplies(for commentId: Int, page: Int = 0) { + guard !isReplyLoading else { return } + isReplyLoading = true + + Task { + do { + let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: page) + + // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 + if let index = self.comments.firstIndex(where: { $0.id == commentId }) { + self.comments[index].replies = result.replies + } + } catch { + print("Error fetching replies: \(error)") + } + self.isReplyLoading = false + } + } + + func reloadReplies(for commentId: Int) { + isReplyLoading = true + + Task { + do { + let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: 0) + + // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 + if let index = self.comments.firstIndex(where: { $0.id == commentId }) { + self.comments[index].replies = result.replies + } + } catch { + print("Error reloading replies: \(error)") + } + self.isReplyLoading = false + } + } + + func postReply() { + guard !currentReplyText.isEmpty, let commentId = replyingToCommentId else { return } + let content = currentReplyText + + Task { + do { + _ = try await postReplyUseCase.execute(feedId: feedId, commentId: commentId, content: content) + + self.currentReplyText = "" + self.replyingToCommentId = nil + + // 대댓글 작성 후 목록 다시 로드 (실제 사용자 정보 반영) + self.reloadReplies(for: commentId) + } catch { + print("Error posting reply: \(error)") + } + } + } } diff --git a/Codive/Features/Feed/Data/Constants/SituationConstants.swift b/Codive/Features/Feed/Data/Constants/SituationConstants.swift index 8ba73c2a..962c199e 100644 --- a/Codive/Features/Feed/Data/Constants/SituationConstants.swift +++ b/Codive/Features/Feed/Data/Constants/SituationConstants.swift @@ -49,4 +49,14 @@ enum SituationConstants { static func getFirstId(from names: Set) -> Int64? { names.compactMap { find(byName: $0)?.id }.first } + + /// 이름 배열로 ID 배열 변환 + static func getIds(from names: [String]) -> [Int64] { + names.compactMap { find(byName: $0)?.id } + } + + /// 이름 Set으로 ID 배열 변환 + static func getIds(from names: Set) -> [Int64] { + getIds(from: Array(names)) + } } diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index cce6f0e2..ddd8355c 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -7,27 +7,170 @@ import Foundation +/// Feed 페이지네이션 결과 +struct FeedPageResult { + let feeds: [Feed] + let nextCursor: String? + let hasNext: Bool +} + /// Feed 데이터를 가져오는 DataSource 프로토콜 protocol FeedDataSource { - /// Feed 목록 조회 + /// Feed 목록 조회 (커서 기반 페이지네이션) func fetchFeeds( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool - ) async throws -> [Feed] + ) async throws -> FeedPageResult /// 특정 Feed의 상세 정보 조회 func fetchFeedDetail(id: Int) async throws -> Feed - /// Feed의 좋아요 조회 + /// Feed의 좋아요 토글 func toggleLike(feedId: Int) async throws - + /// 특정 Feed를 좋아한 사용자 목록 조회 func fetchLikers(feedId: Int) async throws -> [User] } +// MARK: - DefaultFeedDataSource + +final class DefaultFeedDataSource: FeedDataSource { + + private let apiService: FeedAPIServiceProtocol + private let historyAPIService: HistoryAPIServiceProtocol + + init( + apiService: FeedAPIServiceProtocol = FeedAPIService(), + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { + self.apiService = apiService + self.historyAPIService = historyAPIService + } + + func fetchFeeds( + cursor: String?, + limit: Int, + styleIds: [Int]?, + situationIds: [Int]?, + followingOnly: Bool + ) async throws -> FeedPageResult { + let result = try await apiService.fetchFeeds( + cursor: cursor, + size: Int32(limit), + styleIds: styleIds?.map { Int64($0) }, + situationIds: situationIds?.map { Int64($0) }, + followScope: followingOnly ? .following : .all + ) + + let feeds = result.feeds.map { $0.toDomain() } + return FeedPageResult( + feeds: feeds, + nextCursor: result.nextCursor, + hasNext: result.hasNext + ) + } + + func fetchFeedDetail(id: Int) async throws -> Feed { + let dto = try await historyAPIService.fetchHistoryDetail(historyId: Int64(id)) + return dto.toDomain(feedId: id) + } + + func toggleLike(feedId: Int) async throws { + try await apiService.toggleLike(historyId: Int64(feedId)) + } + + func fetchLikers(feedId: Int) async throws -> [User] { + let result = try await apiService.fetchLikers( + historyId: Int64(feedId), + lastLikeId: nil, + size: 100 + ) + return result.likers.map { $0.toDomain() } + } +} + +// MARK: - DTO Mapping + +private extension FeedItemDTO { + func toDomain() -> Feed { + return Feed( + id: Int(feedId), + content: nil, + author: author.toDomain(), + images: imageUrl.map { [FeedImage(imageUrl: $0)] } ?? [], + situationId: nil, + styleIds: nil, + hashtags: nil, + createdAt: createdAt, + likeCount: nil, + isLiked: isLiked, + commentCount: nil + ) + } +} + +private extension FeedAuthorDTO { + func toDomain() -> User { + return User( + id: String(memberId), + nickname: nickname ?? "", + profileImageUrl: profileImageUrl, + isFollowing: isFollowing + ) + } +} + +private extension LikerDTO { + func toDomain() -> User { + return User( + id: String(memberId), + nickname: nickname ?? "", + profileImageUrl: profileImageUrl, + isFollowing: isFollowing + ) + } +} + +extension HistoryDetailDTO { + func toDomain(feedId: Int) -> Feed { + let author = User( + id: String(memberId), + nickname: nickname ?? "", + profileImageUrl: profileImageUrl + ) + + let feedImages = images.map { img in + FeedImage(imageId: img.imageId, imageUrl: img.imageUrl) + } + + let styleIds = styles.map { Int($0.styleId) } + let styleNames = styles.map { $0.styleName } + + // historyDate를 Date로 변환 + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate, .withDashSeparatorInDate] + let createdAtDate = historyDate.flatMap { dateFormatter.date(from: $0) } + + return Feed( + id: feedId, + content: content, + author: author, + images: feedImages, + situationId: situationId.map { Int($0) }, + styleIds: styleIds.isEmpty ? nil : styleIds, + styleNames: styleNames.isEmpty ? nil : styleNames, + hashtags: hashtags, + createdAt: createdAtDate, + likeCount: Int(likeCount), + isLiked: nil, + commentCount: Int(commentCount) + ) + } +} + /// Mock FeedDataSource - 서버 연결 전 테스트용 구현 final class MockFeedDataSource: FeedDataSource { @@ -50,7 +193,7 @@ final class MockFeedDataSource: FeedDataSource { id: "user\(id % 5 + 1)", nickname: "유저\(id % 5 + 1)", profileImageUrl: "https://example.com/profile\(id % 5 + 1).jpg", - bio: "안녕하세요! 패션을 사랑하는 유저입니다 ✨", + bio: "안녕하세요! 패션을 사랑하는 유저입니다", isFollowing: isFollowing ) @@ -89,26 +232,27 @@ final class MockFeedDataSource: FeedDataSource { return Feed( id: id, - content: "오늘의 OOTD #\(id) 🎨\n날씨가 좋아서 가벼운 옷차림으로 나왔어요!", + content: "오늘의 OOTD #\(id)\n날씨가 좋아서 가벼운 옷차림으로 나왔어요!", author: author, images: images, situationId: (id % 3) + 1, styleIds: [(id % 10) + 1, ((id + 1) % 10) + 1], + styleNames: ["캐주얼", "스트릿"], hashtags: ["#OOTD", "#fashion", "#daily"], createdAt: Date().addingTimeInterval(-Double(id * 3600)), - likeCount: Int.random(in: 0...500), // 상세 조회용 + likeCount: Int.random(in: 0...500), isLiked: Bool.random(), commentCount: Int.random(in: 0...50) ) } func fetchFeeds( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]? = nil, situationIds: [Int]? = nil, followingOnly: Bool = false - ) async throws -> [Feed] { + ) async throws -> FeedPageResult { // 네트워크 지연 시뮬레이션 try? await Task.sleep(nanoseconds: 500_000_000) // 0.5초 @@ -136,15 +280,25 @@ final class MockFeedDataSource: FeedDataSource { filteredFeeds = filteredFeeds.filter { $0.author.isFollowing == true } } - // 페이지네이션 - let startIndex = (page - 1) * limit + // 커서 기반 페이지네이션 (Mock: cursor = lastFeedId) + var startIndex = 0 + if let cursor = cursor, let lastId = Int(cursor) { + if let index = filteredFeeds.firstIndex(where: { $0.id == lastId }) { + startIndex = index + 1 + } + } + let endIndex = min(startIndex + limit, filteredFeeds.count) - guard startIndex < filteredFeeds.count else { - return [] + if startIndex >= filteredFeeds.count { + return FeedPageResult(feeds: [], nextCursor: nil, hasNext: false) } - return Array(filteredFeeds[startIndex.. Feed { @@ -177,6 +331,7 @@ final class MockFeedDataSource: FeedDataSource { images: oldFeed.images, situationId: oldFeed.situationId, styleIds: oldFeed.styleIds, + styleNames: oldFeed.styleNames, hashtags: oldFeed.hashtags, createdAt: oldFeed.createdAt, likeCount: newLikeCount, @@ -213,18 +368,24 @@ enum FeedDataSourceError: Error { #if DEBUG /// 프리뷰용 빈 DataSource final class EmptyFeedDataSource: FeedDataSource { - func fetchFeeds(page: Int, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool) async throws -> [Feed] { - [] + func fetchFeeds( + cursor: String?, + limit: Int, + styleIds: [Int]?, + situationIds: [Int]?, + followingOnly: Bool + ) async throws -> FeedPageResult { + FeedPageResult(feeds: [], nextCursor: nil, hasNext: false) } - + func fetchFeedDetail(id: Int) async throws -> Feed { throw FeedDataSourceError.notFound } - + func toggleLike(feedId: Int) async throws {} - + func fetchLikers(feedId: Int) async throws -> [User] { [] } } -#endif +#endif \ No newline at end of file diff --git a/Codive/Features/Feed/Data/FeedAPIService.swift b/Codive/Features/Feed/Data/FeedAPIService.swift new file mode 100644 index 00000000..8c792ffc --- /dev/null +++ b/Codive/Features/Feed/Data/FeedAPIService.swift @@ -0,0 +1,236 @@ +// +// FeedAPIService.swift +// Codive +// +// Created by Gemini on 1/19/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - FeedAPIService Protocol + +protocol FeedAPIServiceProtocol { + func fetchFeeds( + cursor: String?, + size: Int32, + styleIds: [Int64]?, + situationIds: [Int64]?, + followScope: FeedFollowScope + ) async throws -> FeedListResult + + func toggleLike(historyId: Int64) async throws + + func fetchLikers(historyId: Int64, lastLikeId: Int64?, size: Int32) async throws -> LikersResult +} + +// MARK: - Supporting Types + +enum FeedFollowScope { + case all + case following + + var apiValue: Operations.Feed_getFeeds.Input.Query.followScopePayload { + switch self { + case .all: return .ALL + case .following: return .FOLLOWING + } + } +} + +struct FeedListResult { + let feeds: [FeedItemDTO] + let nextCursor: String? + let hasNext: Bool +} + +struct FeedItemDTO { + let feedId: Int64 + let imageUrl: String? + let isLiked: Bool? + let createdAt: Date? + let author: FeedAuthorDTO +} + +struct FeedAuthorDTO { + let memberId: Int64 + let nickname: String? + let profileImageUrl: String? + let isFollowing: Bool? +} + +struct LikersResult { + let likers: [LikerDTO] + let hasNext: Bool +} + +struct LikerDTO { + let likeId: Int64 + let memberId: Int64 + let nickname: String? + let profileImageUrl: String? + let isFollowing: Bool? +} + +// MARK: - FeedAPIService Implementation + +final class FeedAPIService: FeedAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } +} + +// MARK: - Fetch Feeds + +extension FeedAPIService { + + func fetchFeeds( + cursor: String?, + size: Int32, + styleIds: [Int64]?, + situationIds: [Int64]?, + followScope: FeedFollowScope + ) async throws -> FeedListResult { + + let styleIdsStr = styleIds?.map(String.init).joined(separator: ",") + let situationIdsStr = situationIds?.map(String.init).joined(separator: ",") + + let input = Operations.Feed_getFeeds.Input( + query: .init( + followScope: followScope.apiValue, + styleIds: styleIdsStr, + situationIds: situationIdsStr, + size: size, + cursor: cursor + ) + ) + + let response = try await client.Feed_getFeeds(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseFeedListResponse.self, + from: data + ) + + let items = decoded.result?.items ?? [] + let feeds: [FeedItemDTO] = items.map { item in + FeedItemDTO( + feedId: item.feedId ?? 0, + imageUrl: item.imageUrl, + isLiked: item.isLiked, + createdAt: item.createdAt, + author: FeedAuthorDTO( + memberId: item.author?.memberId ?? 0, + nickname: item.author?.nickname, + profileImageUrl: item.author?.profileImageUrl, + isFollowing: item.author?.isFollowing + ) + ) + } + + return FeedListResult( + feeds: feeds, + nextCursor: decoded.result?.nextCursor, + hasNext: decoded.result?.hasNext ?? false + ) + + case .undocumented(statusCode: let code, _): + throw FeedAPIError.serverError(statusCode: code, message: "피드 목록 조회 실패") + } + } +} + +// MARK: - Toggle Like + +extension FeedAPIService { + + func toggleLike(historyId: Int64) async throws { + let input = Operations.Like_toggleLike.Input( + query: .init(historyId: historyId) + ) + + let response = try await client.Like_toggleLike(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw FeedAPIError.serverError(statusCode: code, message: "좋아요 토글 실패") + } + } +} + +// MARK: - Fetch Likers + +extension FeedAPIService { + + func fetchLikers(historyId: Int64, lastLikeId: Int64?, size: Int32) async throws -> LikersResult { + let input = Operations.Like_getLikedMembers.Input( + query: .init( + historyId: historyId, + lastLikeId: lastLikeId, + size: size + ) + ) + + let response = try await client.Like_getLikedMembers(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseLikedMemberPreview.self, + from: data + ) + + guard let result = decoded.result else { + throw FeedAPIError.serverError(statusCode: 200, message: "좋아요 유저 목록 없음") + } + + let likers: [LikerDTO] = result.content?.compactMap { member in + guard let memberId = member.id else { + return nil + } + return LikerDTO( + likeId: member.lastLikeId ?? 0, + memberId: memberId, + nickname: member.nickname, + profileImageUrl: member.imageUrl, + isFollowing: member.followStatus + ) + } ?? [] + + return LikersResult( + likers: likers, + hasNext: !(result.isLast ?? false) + ) + + case .undocumented(statusCode: let code, _): + throw FeedAPIError.serverError(statusCode: code, message: "좋아요 유저 목록 조회 실패") + } + } +} + +// MARK: - FeedAPIError + +enum FeedAPIError: LocalizedError { + case serverError(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + } + } +} diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 3e6817df..d3f9b2f8 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -13,6 +13,44 @@ import OpenAPIRuntime protocol HistoryAPIServiceProtocol { func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 + func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] +} + +// MARK: - History Detail DTO + +struct HistoryDetailDTO { + let memberId: Int64 + let profileImageUrl: String? + let nickname: String? + let images: [HistoryImageDTO] + let likeCount: Int64 + let commentCount: Int64 + let historyDate: String? + let situationId: Int64? + let situationName: String? + let content: String? + let hashtags: [String]? + let styles: [HistoryStyleDTO] +} + +struct HistoryImageDTO { + let imageId: Int64 + let imageUrl: String +} + +struct HistoryStyleDTO { + let styleId: Int64 + let styleName: String +} + +struct ClothTagDTO { + let clothId: Int64 + let name: String? + let brand: String? + let clothImageUrl: String? + let locationX: Double + let locationY: Double } // MARK: - Request Types @@ -91,6 +129,100 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { throw HistoryAPIError.serverError(statusCode: code) } } + + // MARK: - Fetch History Detail + + func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO { + let input = Operations.History_getHistoryDetails.Input( + path: .init(historyId: historyId) + ) + + let response = try await client.History_getHistoryDetails(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseDailyHistoryResponse.self, + from: data + ) + + guard let result = decoded.result else { + throw HistoryAPIError.noData + } + + return HistoryDetailDTO( + memberId: result.memberId ?? 0, + profileImageUrl: result.profileImageUrl, + nickname: result.nickname, + images: result.images?.compactMap { img in + guard let imageUrl = img.imageUrl else { return nil } + return HistoryImageDTO( + imageId: img.imageId ?? 0, + imageUrl: imageUrl + ) + } ?? [], + likeCount: result.likeCount ?? 0, + commentCount: result.commentCount ?? 0, + historyDate: result.historyDate, + situationId: result.situationId, + situationName: result.situationName, + content: result.content, + hashtags: result.hashtags?.compactMap { $0 as? String }, + styles: result.styles?.compactMap { style in + guard let styleId = style.styleId, let styleName = style.styleName else { return nil } + return HistoryStyleDTO(styleId: styleId, styleName: styleName) + } ?? [] + ) + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } + + // MARK: - Fetch Cloth Tags + + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] { + let input = Operations.History_getHistoryClothTags.Input( + path: .init(historyImageId: historyImageId) + ) + + let response = try await client.History_getHistoryClothTags(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseHistoryClothTagListResponse.self, + from: data + ) + + guard let payloads = decoded.result?.payloads else { + throw HistoryAPIError.noData + } + + return payloads.compactMap { tag in + guard let clothId = tag.clothId, + let locationX = tag.locationX, + let locationY = tag.locationY else { + return nil + } + return ClothTagDTO( + clothId: clothId, + name: tag.name, + brand: tag.brand, + clothImageUrl: tag.clothImageUrl, + locationX: locationX, + locationY: locationY + ) + } + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } } // MARK: - Response Types diff --git a/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift b/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift index 4dfdb1b1..21b20671 100644 --- a/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift +++ b/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift @@ -51,8 +51,14 @@ final class MockFeedRepository: FeedRepository { } } - func fetchFeeds(page: Int, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool) async throws -> [Feed] { - return [] + func fetchFeeds( + cursor: String?, + limit: Int, + styleIds: [Int]?, + situationIds: [Int]?, + followingOnly: Bool + ) async throws -> FeedPageResult { + return FeedPageResult(feeds: [], nextCursor: nil, hasNext: false) } func fetchFeedDetail(feedId: Int) async throws -> Feed { @@ -72,5 +78,29 @@ final class MockFeedRepository: FeedRepository { .init(id: "4", nickname: "옷잘알", profileImageUrl: nil) ] } + + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTag] { + print("Fetching cloth tags for historyImageId: \(historyImageId)") + return [ + ClothTag( + id: UUID(), + clothId: 101, + brand: "Zara", + name: "Black T-Shirt", + imageUrl: "https://via.placeholder.com/100x100", + locationX: 0.5, + locationY: 0.4 + ), + ClothTag( + id: UUID(), + clothId: 102, + brand: "Uniqlo", + name: "Blue Jeans", + imageUrl: "https://via.placeholder.com/100x100", + locationX: 0.5, + locationY: 0.7 + ) + ] + } } #endif diff --git a/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift index 8467c76b..7be8b914 100644 --- a/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift @@ -11,20 +11,25 @@ import Foundation /// DataSource로부터 데이터를 가져와서 Domain Layer에 제공 final class FeedRepositoryImpl: FeedRepository { private let dataSource: FeedDataSource + private let historyAPIService: HistoryAPIServiceProtocol - init(dataSource: FeedDataSource) { + init( + dataSource: FeedDataSource, + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { self.dataSource = dataSource + self.historyAPIService = historyAPIService } func fetchFeeds( - page: Int, + cursor: String? = nil, limit: Int, styleIds: [Int]? = nil, situationIds: [Int]? = nil, followingOnly: Bool = false - ) async throws -> [Feed] { + ) async throws -> FeedPageResult { return try await dataSource.fetchFeeds( - page: page, + cursor: cursor, limit: limit, styleIds: styleIds, situationIds: situationIds, @@ -39,8 +44,23 @@ final class FeedRepositoryImpl: FeedRepository { func toggleLike(feedId: Int) async throws { try await dataSource.toggleLike(feedId: feedId) } - + func fetchLikers(feedId: Int) async throws -> [User] { return try await dataSource.fetchLikers(feedId: feedId) } + + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTag] { + let tagDTOs = try await historyAPIService.fetchClothTags(historyImageId: historyImageId) + return tagDTOs.map { dto in + ClothTag( + id: UUID(), + clothId: Int(dto.clothId), + brand: dto.brand ?? TextLiteral.Feed.defaultBrand, + name: dto.name ?? TextLiteral.Feed.defaultProductName, + imageUrl: dto.clothImageUrl, + locationX: CGFloat(dto.locationX), + locationY: CGFloat(dto.locationY) + ) + } + } } diff --git a/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift b/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift index 18ee3216..9495c0e0 100644 --- a/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift @@ -8,21 +8,24 @@ import Foundation protocol FeedRepository { - // MARK: - 피드 전체 조회 + // MARK: - 피드 전체 조회 (커서 기반 페이지네이션) func fetchFeeds( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool - ) async throws -> [Feed] - + ) async throws -> FeedPageResult + // MARK: - 피드 상세 조회 func fetchFeedDetail(feedId: Int) async throws -> Feed - + // MARK: - 좋아요 토글 func toggleLike(feedId: Int) async throws - + // MARK: - 좋아요 누른 유저 목록 조회 func fetchLikers(feedId: Int) async throws -> [User] + + // MARK: - 이미지의 옷 태그 조회 + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTag] } diff --git a/Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift b/Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift new file mode 100644 index 00000000..35e2ba56 --- /dev/null +++ b/Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift @@ -0,0 +1,28 @@ +// +// FetchClothTagsUseCase.swift +// Codive +// +// Created by Claude on 2026/01/22. +// + +import Foundation + +protocol FetchClothTagsUseCase { + func execute(historyImageId: Int64) async throws -> [ClothTag] +} + +final class DefaultFetchClothTagsUseCase: FetchClothTagsUseCase { + + // MARK: - Properties + private let feedRepository: FeedRepository + + // MARK: - Initializer + init(feedRepository: FeedRepository) { + self.feedRepository = feedRepository + } + + // MARK: - FetchClothTagsUseCase + func execute(historyImageId: Int64) async throws -> [ClothTag] { + return try await feedRepository.fetchClothTags(historyImageId: historyImageId) + } +} diff --git a/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift b/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift index 0f52b611..c854d6fe 100644 --- a/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift +++ b/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift @@ -9,21 +9,21 @@ import Foundation /// Feed 목록을 가져오는 UseCase protocol FetchFeedsUseCase { - /// Feed 목록을 페이지 단위로 조회 + /// Feed 목록을 커서 단위로 조회 /// - Parameters: - /// - page: 페이지 번호 (1부터 시작) + /// - cursor: 페이지네이션 커서 (nil이면 처음부터) /// - limit: 한 페이지당 가져올 개수 /// - styleIds: 스타일 필터 (nil 또는 빈 배열이면 전체) /// - situationIds: 상황 필터 (nil 또는 빈 배열이면 전체) /// - followingOnly: 팔로잉한 사용자만 (기본값: false) - /// - Returns: Feed 배열 + /// - Returns: FeedPageResult (feeds, nextCursor, hasNext) func execute( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool - ) async throws -> [Feed] + ) async throws -> FeedPageResult } final class DefaultFetchFeedsUseCase: FetchFeedsUseCase { @@ -34,14 +34,14 @@ final class DefaultFetchFeedsUseCase: FetchFeedsUseCase { } func execute( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]? = nil, situationIds: [Int]? = nil, followingOnly: Bool = false - ) async throws -> [Feed] { + ) async throws -> FeedPageResult { return try await repository.fetchFeeds( - page: page, + cursor: cursor, limit: limit, styleIds: styleIds, situationIds: situationIds, diff --git a/Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift b/Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift new file mode 100644 index 00000000..90b6d5ae --- /dev/null +++ b/Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift @@ -0,0 +1,28 @@ +// +// ToggleLikeUseCase.swift +// Codive +// +// Created by Claude on 2026/01/22. +// + +import Foundation + +protocol ToggleLikeUseCase { + func execute(feedId: Int) async throws +} + +final class DefaultToggleLikeUseCase: ToggleLikeUseCase { + + // MARK: - Properties + private let feedRepository: FeedRepository + + // MARK: - Initializer + init(feedRepository: FeedRepository) { + self.feedRepository = feedRepository + } + + // MARK: - ToggleLikeUseCase + func execute(feedId: Int) async throws { + try await feedRepository.toggleLike(feedId: feedId) + } +} diff --git a/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift index cb34419a..0e1385ed 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift @@ -95,6 +95,7 @@ final class PhotoTagViewModel: ObservableObject { clothId: product.id, brand: product.brand ?? "", name: product.name ?? "", + imageUrl: product.imageUrl, locationX: 0.5, locationY: 0.5 ) diff --git a/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift b/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift index 2acefcb4..6b85138c 100644 --- a/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift +++ b/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift @@ -18,7 +18,39 @@ struct FeedContentSection: View { let onLikeTap: () -> Void let onCommentTap: () -> Void let onLikesCountTap: () -> Void - + + // content에서 해시태그 부분만 주황색으로 표시 + private var attributedContent: AttributedString { + var attributed = AttributedString(content) + + // 해시태그 패턴 찾기 (#로 시작하는 단어) + let pattern = "#[가-힣a-zA-Z0-9_]+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + attributed.foregroundColor = Color.Codive.grayscale1 + return attributed + } + + let nsString = content as NSString + let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsString.length)) + + // 기본 색상 설정 + attributed.foregroundColor = Color.Codive.grayscale1 + + // 각 해시태그 부분만 주황색으로 + for match in matches { + if let range = Range(match.range, in: content) { + let start = AttributedString.Index(range.lowerBound, within: attributed) + let end = AttributedString.Index(range.upperBound, within: attributed) + + if let start = start, let end = end { + attributed[start.. Bool { + guard let destination = destination else { return false } + if case .comment = destination { + return true + } + return false } } @@ -149,6 +170,7 @@ struct FeedDetailView: View { FeedDetailView( viewModel: viewModel, - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + commentDIContainer: CommentDIContainer(navigationRouter: navigationRouter) ) } diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift index ac448ea7..a9d8a2de 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift @@ -48,7 +48,26 @@ struct FeedImageSlider: View { .frame(width: geometry.size.width, height: geometry.size.height) } } - + + // 페이지 인디케이터 (우측 상단) + if imageUrls.count > 1 { + VStack { + HStack { + Spacer() + Text("\(currentIndex + 1)/\(imageUrls.count)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black.opacity(0.5)) + .clipShape(Capsule()) + } + Spacer() + } + .padding(.trailing, 20) + .padding(.top, 20) + } + Button(action: { withAnimation { onTagButtonTap() @@ -81,7 +100,7 @@ struct FeedImageSlider: View { "https://via.placeholder.com/300x400/0000FF" ], tags: [ - [ClothTag(id: UUID(), clothId: 1, brand: "Typeservice", name: "Layered Henry Neck", locationX: 0.3, locationY: 0.4)], + [ClothTag(id: UUID(), clothId: 1, brand: "Typeservice", name: "Layered Henry Neck", imageUrl: nil, locationX: 0.3, locationY: 0.4)], [] ], currentIndex: .constant(0), diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift index 6764ade8..87251c0e 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift @@ -26,6 +26,7 @@ struct LinkedProductListView: View { } }, label: { ProductThumbnailItem( + tag: tag, isSelected: selectedTagId == tag.id ) }) @@ -39,19 +40,41 @@ struct LinkedProductListView: View { // MARK: - Item View private struct ProductThumbnailItem: View { + let tag: ClothTag let isSelected: Bool - + var body: some View { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.1)) .frame(width: 60, height: 60) .overlay( - Image(systemName: "tshirt") - .foregroundStyle(Color.gray) + Group { + if let imageUrl = tag.imageUrl { + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + Image(systemName: "tshirt") + .foregroundStyle(Color.gray) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + } else { + Image(systemName: "tshirt") + .foregroundStyle(Color.gray) + } + } ) + .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.black : Color.clear, lineWidth: 1) + .stroke(isSelected ? Color.black : Color.clear, lineWidth: 2) ) .padding(.bottom, 2) } @@ -61,8 +84,8 @@ private struct ProductThumbnailItem: View { #Preview { LinkedProductListView( tags: [ - ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", locationX: 0, locationY: 0), - ClothTag(id: UUID(), clothId: 2, brand: "Adidas", name: "Pants", locationX: 0, locationY: 0) + ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", imageUrl: nil, locationX: 0, locationY: 0), + ClothTag(id: UUID(), clothId: 2, brand: "Adidas", name: "Pants", imageUrl: nil, locationX: 0, locationY: 0) ], selectedTagId: .constant(nil) ) diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift index 7e223578..7d314375 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift @@ -99,7 +99,7 @@ struct RemoteTaggableImageView: View { RemoteTaggableImageView( imageUrl: "https://via.placeholder.com/300x400", tags: .constant([ - ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", locationX: 0.5, locationY: 0.3) + ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", imageUrl: nil, locationX: 0.5, locationY: 0.3) ]), selectedTagId: nil ) { tagId in diff --git a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift index 156453d0..32457709 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift @@ -28,8 +28,9 @@ final class FeedDetailViewModel: ObservableObject { private let feedId: Int private let fetchFeedDetailUseCase: FetchFeedDetailUseCase - private let fetchLikersUseCase: FetchFeedLikersUseCase - private let feedRepository: FeedRepository + private let fetchLikersUseCase: FetchFeedLikersUseCase + private let toggleLikeUseCase: ToggleLikeUseCase + private let fetchClothTagsUseCase: FetchClothTagsUseCase private let navigationRouter: NavigationRouter private let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -43,13 +44,15 @@ final class FeedDetailViewModel: ObservableObject { feedId: Int, fetchFeedDetailUseCase: FetchFeedDetailUseCase, fetchLikersUseCase: FetchFeedLikersUseCase, - feedRepository: FeedRepository, + toggleLikeUseCase: ToggleLikeUseCase, + fetchClothTagsUseCase: FetchClothTagsUseCase, navigationRouter: NavigationRouter ) { self.feedId = feedId self.fetchFeedDetailUseCase = fetchFeedDetailUseCase self.fetchLikersUseCase = fetchLikersUseCase - self.feedRepository = feedRepository + self.toggleLikeUseCase = toggleLikeUseCase + self.fetchClothTagsUseCase = fetchClothTagsUseCase self.navigationRouter = navigationRouter } @@ -67,11 +70,11 @@ final class FeedDetailViewModel: ObservableObject { // 데이터 가공 self.feed = fetchedFeed self.imageUrls = fetchedFeed.images.map { $0.imageUrl } - self.displayableTags = mapToDisplayableTags(from: fetchedFeed.images) self.formattedDate = format(date: fetchedFeed.createdAt) + self.displayableStyles = fetchedFeed.styleNames ?? [] - // TODO: styleIds를 실제 스타일 이름으로 변환하는 로직 구현 필요 - self.displayableStyles = [] // 현재는 임시로 빈 배열 할당 + // 각 이미지의 태그를 API로 가져오기 + self.displayableTags = await loadTagsForImages(images: fetchedFeed.images) } catch { errorMessage = TextLiteral.Feed.loadDetailFailed @@ -86,20 +89,34 @@ final class FeedDetailViewModel: ObservableObject { } // MARK: - Data Transformation - - private func mapToDisplayableTags(from images: [FeedImage]) -> [[ClothTag]] { - images.map { image in - image.tags.map { tag in - // TODO: API 연동 시 clothId로 실제 옷 정보(brand, name) 조회하여 사용 - ClothTag( - id: tag.id, - clothId: tag.clothId, - brand: TextLiteral.Feed.defaultBrand, - name: TextLiteral.Feed.defaultProductName + " \(tag.clothId)", - locationX: CGFloat(tag.locationX), - locationY: CGFloat(tag.locationY) - ) + + private func loadTagsForImages(images: [FeedImage]) async -> [[ClothTag]] { + await withTaskGroup(of: (Int, [ClothTag]).self) { group in + // 각 이미지의 태그를 병렬로 가져오기 + for (index, image) in images.enumerated() { + group.addTask { [weak self] in + guard let self = self, + let imageId = image.imageId else { + return (index, []) + } + + do { + let clothTags = try await self.fetchClothTagsUseCase.execute(historyImageId: imageId) + return (index, clothTags) + } catch { + print("Failed to load tags for image \(imageId): \(error)") + return (index, []) + } + } + } + + // 결과를 순서대로 정렬 + var result: [(Int, [ClothTag])] = [] + for await value in group { + result.append(value) } + result.sort { $0.0 < $1.0 } + return result.map { $0.1 } } } @@ -129,6 +146,7 @@ final class FeedDetailViewModel: ObservableObject { images: currentFeed.images, situationId: currentFeed.situationId, styleIds: currentFeed.styleIds, + styleNames: currentFeed.styleNames, hashtags: currentFeed.hashtags, createdAt: currentFeed.createdAt, likeCount: newLikeCount, @@ -138,7 +156,7 @@ final class FeedDetailViewModel: ObservableObject { // 서버 요청 do { - try await feedRepository.toggleLike(feedId: currentFeed.id) + try await toggleLikeUseCase.execute(feedId: currentFeed.id) } catch { // 실패 시 롤백 feed = originalFeed diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index 59ae58c0..f5a049a9 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Foundation struct FeedView: View { @@ -15,7 +16,7 @@ struct FeedView: View { // MARK: Filter States // Top Bar States @State private var isFollowingSelected: Bool = false - @State private var selectedCategory: String = "" + @State private var selectedCategory: Set = [] // Bottom Sheet States @State private var isShowingFilterSheet: Bool = false @@ -32,18 +33,31 @@ struct FeedView: View { ] private let styleCategories = [ - TextLiteral.Add.styleCasual, TextLiteral.Add.styleLoving, TextLiteral.Add.styleMinimal, - TextLiteral.Add.styleVintage, TextLiteral.Add.styleSporty, TextLiteral.Add.styleStreet, - TextLiteral.Add.styleChic, TextLiteral.Add.styleOffice, TextLiteral.Add.styleClassic, + TextLiteral.Add.styleLoving, TextLiteral.Add.styleMinimal, TextLiteral.Add.styleVintage, + TextLiteral.Add.styleSporty, TextLiteral.Add.styleStreet, TextLiteral.Add.styleChic, + TextLiteral.Add.styleOffice, TextLiteral.Add.styleCasual, TextLiteral.Add.styleClassic, TextLiteral.Add.styleHighteen ] - + + // 선택된 카테고리를 앞으로, 나머지를 뒤로 정렬 + private var sortedCategories: [String] { + let selected = Array(selectedCategory).sorted { a, b in + guard let indexA = styleCategories.firstIndex(of: a), + let indexB = styleCategories.firstIndex(of: b) else { + return false + } + return indexA < indexB + } + let notSelected = styleCategories.filter { !selectedCategory.contains($0) } + return selected + notSelected + } + // MARK: - Body var body: some View { VStack { FeedFilterBar( isFollowingSelected: $isFollowingSelected, - categories: styleCategories, + categories: sortedCategories, selectedCategory: $selectedCategory ) { isShowingFilterSheet = true @@ -56,7 +70,8 @@ struct FeedView: View { Task { await viewModel.applyFilters() } } .onChange(of: selectedCategory) { _ in - viewModel.selectedStyleIds = nil + let styleIds = StyleConstants.getIds(from: selectedCategory).map { Int($0) } + viewModel.selectedStyleIds = styleIds.isEmpty ? nil : styleIds viewModel.selectedSituationIds = nil Task { await viewModel.applyFilters() } } @@ -69,6 +84,12 @@ struct FeedView: View { selectedSheetSituations.removeAll() } onApply: { isShowingFilterSheet = false + // Update top category with selected styles from sheet + selectedCategory = selectedSheetStyles + let styleIds = StyleConstants.getIds(from: selectedSheetStyles).map { Int($0) } + let situationIds = SituationConstants.getIds(from: selectedSheetSituations).map { Int($0) } + viewModel.selectedStyleIds = styleIds.isEmpty ? nil : styleIds + viewModel.selectedSituationIds = situationIds.isEmpty ? nil : situationIds Task { await viewModel.applyFilters() } } .presentationDetents([.height(500)]) @@ -97,7 +118,7 @@ struct FeedView: View { } else { FeedEmptyView(type: .noFeeds) { viewModel.clearFiltersAndReload() - selectedCategory = "" + selectedCategory.removeAll() } } } else if let errorMessage = viewModel.errorMessage { @@ -133,7 +154,7 @@ private struct FeedCellView: View { } label: { CustomFeedCard( imageUrl: feed.images.first?.imageUrl ?? "", - profileImageUrl: feed.author.profileImageUrl ?? "sample_profile", + profileImageUrl: feed.author.profileImageUrl ?? "", nickname: feed.author.nickname, isLiked: Binding( get: { feed.isLiked ?? false }, diff --git a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift index 2ca87198..fbe3129f 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift @@ -35,9 +35,9 @@ final class FeedViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let fetchFeedsUseCase: FetchFeedsUseCase - private let feedRepository: FeedRepository - private var currentPage: Int = 1 + private let toggleLikeUseCase: ToggleLikeUseCase private let pageSize: Int = 20 + private var nextCursor: String? private var hasMorePages: Bool = true // MARK: - Initialization @@ -45,11 +45,11 @@ final class FeedViewModel: ObservableObject { init( navigationRouter: NavigationRouter, fetchFeedsUseCase: FetchFeedsUseCase, - feedRepository: FeedRepository + toggleLikeUseCase: ToggleLikeUseCase ) { self.navigationRouter = navigationRouter self.fetchFeedsUseCase = fetchFeedsUseCase - self.feedRepository = feedRepository + self.toggleLikeUseCase = toggleLikeUseCase } // MARK: - Public Methods @@ -66,19 +66,20 @@ final class FeedViewModel: ObservableObject { isLoading = true errorMessage = nil + nextCursor = nil do { - let newFeeds = try await fetchFeedsUseCase.execute( - page: 1, + let result = try await fetchFeedsUseCase.execute( + cursor: nil, limit: pageSize, styleIds: selectedStyleIds, situationIds: selectedSituationIds, followingOnly: followingOnly ) - feeds = newFeeds - currentPage = 1 - hasMorePages = newFeeds.count == pageSize + feeds = result.feeds + nextCursor = result.nextCursor + hasMorePages = result.hasNext } catch { errorMessage = "Feed를 불러오는데 실패했습니다: \(error.localizedDescription)" feeds = [] @@ -95,18 +96,17 @@ final class FeedViewModel: ObservableObject { isLoading = true do { - let nextPage = currentPage + 1 - let newFeeds = try await fetchFeedsUseCase.execute( - page: nextPage, + let result = try await fetchFeedsUseCase.execute( + cursor: nextCursor, limit: pageSize, styleIds: selectedStyleIds, situationIds: selectedSituationIds, followingOnly: followingOnly ) - feeds.append(contentsOf: newFeeds) - currentPage = nextPage - hasMorePages = newFeeds.count == pageSize + feeds.append(contentsOf: result.feeds) + nextCursor = result.nextCursor + hasMorePages = result.hasNext } catch { errorMessage = "더 많은 Feed를 불러오는데 실패했습니다: \(error.localizedDescription)" } @@ -157,6 +157,7 @@ final class FeedViewModel: ObservableObject { images: originalFeed.images, situationId: originalFeed.situationId, styleIds: originalFeed.styleIds, + styleNames: originalFeed.styleNames, hashtags: originalFeed.hashtags, createdAt: originalFeed.createdAt, likeCount: newLikeCount, @@ -166,7 +167,7 @@ final class FeedViewModel: ObservableObject { // 서버에 요청 do { - try await feedRepository.toggleLike(feedId: feedId) + try await toggleLikeUseCase.execute(feedId: feedId) } catch { // 에러 시 롤백 feeds[index] = originalFeed diff --git a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift index d375e296..2432d46b 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift @@ -91,7 +91,7 @@ final class AddCodiDetailViewModel: ObservableObject, DraggableImageViewModelPro let newImage = DraggableImageEntity( id: product.id, - name: product.imageName, + name: product.imageUrl ?? product.imageName ?? "", position: CGPoint( x: centerX + randomOffsetX, y: centerY + randomOffsetY diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 74ca3784..c502b247 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -22,6 +22,8 @@ struct MainTabView: View { private let notificationDIContainer: NotificationDIContainer private let commentDIContainer: CommentDIContainer private let lookBookDIContainer: LookBookDIContainer + private let settingDIContainer: SettingDIContainer + private let profileDIContainer: ProfileDIContainer // MARK: - Initializer init(appDIContainer: AppDIContainer) { @@ -34,6 +36,8 @@ struct MainTabView: View { self.notificationDIContainer = appDIContainer.makeNotificationDIContainer() self.commentDIContainer = appDIContainer.makeCommentDIContainer() self.lookBookDIContainer = appDIContainer.makeLookBookDIContainer() + self.settingDIContainer = appDIContainer.makeSettingDIContainer() + self.profileDIContainer = appDIContainer.makeProfileDIContainer() self._navigationRouter = ObservedObject(wrappedValue: appDIContainer.navigationRouter) let viewModel = MainTabViewModel(navigationRouter: appDIContainer.navigationRouter) @@ -72,7 +76,7 @@ struct MainTabView: View { case .feed: FeedView(viewModel: feedDIContainer.makeFeedViewModel()) case .profile: - ProfileView(navigationRouter: navigationRouter) + profileDIContainer.makeProfileView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -150,8 +154,8 @@ struct MainTabView: View { } } - // 기본 규칙: Add 탭에서만 상단바 숨김 - return viewModel.selectedTab != .add + // 기본 규칙: Add, Profile 탭에서만 상단바 숨김 + return viewModel.selectedTab != .add && viewModel.selectedTab != .profile } /// 하단 탭 바를 표시할지 여부 @@ -178,6 +182,8 @@ struct MainTabView: View { notificationDIContainer.makeNotificationView() case .feedDetail(let feedId): feedDIContainer.makeFeedDetailView(feedId: feedId) + case .otherProfile: + feedDIContainer.feedViewFactory.makeView(for: destination) case .comment(let feedId): commentDIContainer.makeCommentView(feedId: feedId) case .editCategory: @@ -187,9 +193,11 @@ struct MainTabView: View { case .favoriteCodiList(let showHeart): FavoriteCodiView(showHeart: showHeart, navigationRouter: navigationRouter) case .settings: - ProfileSettingView(navigationRouter: navigationRouter) - case .followList(let mode): - FollowListView(mode: mode, navigationRouter: navigationRouter) + settingDIContainer.makeSettingView() + case .profileSetting: + profileDIContainer.makeProfileSettingView() + case .followList(let mode, let memberId): + profileDIContainer.makeFollowListView(mode: mode, memberId: memberId) case .myCloset: closetDIContainer.makeMyClosetView() case .clothDetail, .clothEdit: diff --git a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift index a40915e3..e7f7cfb1 100644 --- a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift +++ b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift @@ -58,10 +58,9 @@ struct NotificationRow: View { } private var defaultImage: some View { - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } diff --git a/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift new file mode 100644 index 00000000..f2fd3a05 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift @@ -0,0 +1,44 @@ +// +// ProfileDataSource.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +protocol ProfileDataSourceProtocol { + func fetchMyProfile() async throws -> MyProfileInfo + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo + func checkNicknameDuplicate(nickname: String) async throws -> Bool + func uploadProfileImage(_ imageData: Data) async throws -> String +} + +final class ProfileDataSource: ProfileDataSourceProtocol { + private let apiService: ProfileAPIServiceProtocol + + init(apiService: ProfileAPIServiceProtocol = ProfileAPIService()) { + self.apiService = apiService + } + + func fetchMyProfile() async throws -> MyProfileInfo { + return try await apiService.fetchMyProfile() + } + + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + return try await apiService.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) + } + + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo { + return try await apiService.updateProfile(nickname: nickname, bio: bio, isPublic: isPublic, currentImageUrl: currentImageUrl) + } + + func checkNicknameDuplicate(nickname: String) async throws -> Bool { + return try await apiService.checkNicknameDuplicate(nickname: nickname) + } + + func uploadProfileImage(_ imageData: Data) async throws -> String { + return try await apiService.uploadProfileImage(imageData) + } +} diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift new file mode 100644 index 00000000..bcd2a836 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -0,0 +1,257 @@ +// +// ProfileAPIService.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime +import CryptoKit + +// MARK: - Profile API Service Protocol + +protocol ProfileAPIServiceProtocol { + func fetchMyProfile() async throws -> MyProfileInfo + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo + func checkNicknameDuplicate(nickname: String) async throws -> Bool + func uploadProfileImage(_ imageData: Data) async throws -> String +} + +// MARK: - Profile API Service Implementation + +final class ProfileAPIService: ProfileAPIServiceProtocol { + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + func fetchMyProfile() async throws -> MyProfileInfo { + let response = try await client.Member_getMyInfo() + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + // API 응답을 BaseResponseMemberInfoResponse로 decode + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseMemberInfoResponse.self, + from: data + ) + + guard let memberInfo = apiResponse.result else { + throw ProfileAPIError.invalidResponse + } + + guard let userId = memberInfo.memberId, + let nickname = memberInfo.nickname else { + throw ProfileAPIError.invalidResponse + } + + return MyProfileInfo( + userId: Int(userId), + nickname: nickname, + displayName: nickname, + introduction: memberInfo.bio, + profileImageUrl: memberInfo.profileImageUrl, + followerCount: Int(memberInfo.followerCount ?? 0), + followingCount: Int(memberInfo.followingCount ?? 0) + ) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "내 프로필 조회 실패") + } + } + + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + let response = try await client.Member_getFollows( + query: Operations.Member_getFollows.Input.Query( + memberId: Int64(memberId), + lastFollowId: lastFollowId, + isFollowing: isFollowing, + size: size + ) + ) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseFollowMemberResponse.self, + from: data + ) + + let members = apiResponse.result?.content ?? [] + let followers: [SimpleUser] = members.compactMap { member -> SimpleUser? in + guard let userId = member.memberId else { return nil } + return SimpleUser( + userId: Int(userId), + nickname: member.nickname ?? "", + handle: member.nickname ?? "", + avatarURL: member.profileImageUrl.flatMap { URL(string: $0) } + ) + } + + return FollowListResult( + followers: followers, + isLast: apiResponse.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "팔로우 목록 조회 실패") + } + } + + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo { + let visibility: Components.Schemas.ProfileUpdateRequest.visibilityPayload = isPublic ? .PUBLIC : .PRIVATE + + let requestBody = Components.Schemas.ProfileUpdateRequest( + nickname: nickname, + bio: bio, + visibility: visibility, + profileImageUrl: currentImageUrl ?? "", + profileBackImageUrl: "" + ) + + let response = try await client.Member_updateProfile( + .init(body: .json(requestBody)) + ) + + switch response { + case .ok: + // 204 응답은 result가 없으므로, 수정된 정보를 다시 fetch + return try await fetchMyProfile() + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "프로필 수정 실패 (상태코드: \(code))") + } + } + + func checkNicknameDuplicate(nickname: String) async throws -> Bool { + let requestBody = Components.Schemas.DuplicatedIdCheckRequest(nickname: nickname) + + let response = try await client.Member_checkDuplicateNickname( + .init(body: .json(requestBody)) + ) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseDuplicatedIdCheckResponse.self, + from: data + ) + + // result.duplicated가 true면 중복된 것 (사용 불가) + return apiResponse.result?.duplicated ?? false + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "닉네임 중복확인 실패 (상태코드: \(code))") + } + } + + func uploadProfileImage(_ imageData: Data) async throws -> String { + let md5Hash = calculateMD5(from: imageData) + let uploadPayload = Components.Schemas.ClothImagesUploadRequestPayload(fileExtension: .JPEG, md5Hashes: md5Hash) + let requestBody = Components.Schemas.ClothImagesUploadRequest(payloads: [uploadPayload]) + + let response = try await client.Cloth_getClothUploadPresignedUrl( + .init(body: .json(requestBody)) + ) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseClothImagesPresignedUrlResponse.self, + from: data + ) + + guard let urls = apiResponse.result?.urls, !urls.isEmpty else { + throw ProfileAPIError.invalidResponse + } + + let presignedUrl = urls[0] + + // S3에 직접 업로드 + try await uploadImageToS3(presignedUrl: presignedUrl, imageData: imageData, contentMD5: md5Hash) + + return extractFinalUrl(from: presignedUrl) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "이미지 업로드 실패 (상태코드: \(code))") + } + } + + private func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws { + guard let url = URL(string: presignedUrl) else { + throw ProfileAPIError.invalidUrl + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") + request.httpBody = imageData + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProfileAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ProfileAPIError.s3UploadFailed(statusCode: httpResponse.statusCode) + } + } +} + +// MARK: - Private Helpers + +private extension ProfileAPIService { + func calculateMD5(from data: Data) -> String { + let digest = Insecure.MD5.hash(data: data) + return Data(digest).base64EncodedString() + } + + func extractFinalUrl(from presignedUrl: String) -> String { + guard let url = URL(string: presignedUrl), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return presignedUrl + } + components.query = nil + return components.string ?? presignedUrl + } +} + +// MARK: - Profile API Error + +enum ProfileAPIError: LocalizedError { + case serverError(statusCode: Int, message: String) + case invalidResponse + case invalidUrl + case s3UploadFailed(statusCode: Int) + + var errorDescription: String? { + switch self { + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + case .invalidResponse: + return "올바르지 않은 응답 형식입니다" + case .invalidUrl: + return "유효하지 않은 URL입니다" + case .s3UploadFailed(let statusCode): + return "S3 업로드 실패 (상태 코드: \(statusCode))" + } + } +} diff --git a/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift new file mode 100644 index 00000000..2f3b3899 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift @@ -0,0 +1,36 @@ +// +// ProfileRepositoryImpl.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +final class ProfileRepositoryImpl: ProfileRepository { + private let dataSource: ProfileDataSourceProtocol + + init(dataSource: ProfileDataSourceProtocol) { + self.dataSource = dataSource + } + + func fetchMyProfile() async throws -> MyProfileInfo { + return try await dataSource.fetchMyProfile() + } + + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + return try await dataSource.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) + } + + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo { + return try await dataSource.updateProfile(nickname: nickname, bio: bio, isPublic: isPublic, currentImageUrl: currentImageUrl) + } + + func checkNicknameDuplicate(nickname: String) async throws -> Bool { + return try await dataSource.checkNicknameDuplicate(nickname: nickname) + } + + func uploadProfileImage(_ imageData: Data) async throws -> String { + return try await dataSource.uploadProfileImage(imageData) + } +} diff --git a/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift b/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift new file mode 100644 index 00000000..cb3ca000 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift @@ -0,0 +1,21 @@ +// +// ProfileEntity.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +struct MyProfileInfo { + let userId: Int + let nickname: String + let displayName: String + let introduction: String? + let profileImageUrl: String? + let followerCount: Int + let followingCount: Int +} + +struct FollowListResult { + let followers: [SimpleUser] + let isLast: Bool +} diff --git a/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift new file mode 100644 index 00000000..a5f69486 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift @@ -0,0 +1,16 @@ +// +// ProfileRepository.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +protocol ProfileRepository { + func fetchMyProfile() async throws -> MyProfileInfo + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo + func checkNicknameDuplicate(nickname: String) async throws -> Bool + func uploadProfileImage(_ imageData: Data) async throws -> String +} diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift new file mode 100644 index 00000000..f8b3f276 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchFollowsUseCase.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +protocol FetchFollowsUseCase { + func execute(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult +} + +final class DefaultFetchFollowsUseCase: FetchFollowsUseCase { + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func execute(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + return try await repository.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) + } +} diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift new file mode 100644 index 00000000..3ac629ef --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchMyProfileUseCase.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +protocol FetchMyProfileUseCase { + func execute() async throws -> MyProfileInfo +} + +final class DefaultFetchMyProfileUseCase: FetchMyProfileUseCase { + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func execute() async throws -> MyProfileInfo { + return try await repository.fetchMyProfile() + } +} diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift new file mode 100644 index 00000000..e1cfb197 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift @@ -0,0 +1,46 @@ +// +// UpdateProfileUseCase.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +protocol UpdateProfileUseCase { + func execute( + nickname: String, + bio: String, + isPublic: Bool, + imageData: Data? + ) async throws -> MyProfileInfo +} + +final class DefaultUpdateProfileUseCase: UpdateProfileUseCase { + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func execute( + nickname: String, + bio: String, + isPublic: Bool, + imageData: Data? + ) async throws -> MyProfileInfo { + // 이미지가 있으면 먼저 업로드 + var imageUrlToUpdate: String? = nil + if let imageData = imageData { + imageUrlToUpdate = try await repository.uploadProfileImage(imageData) + } + + // 프로필 수정 + return try await repository.updateProfile( + nickname: nickname, + bio: bio, + isPublic: isPublic, + currentImageUrl: imageUrlToUpdate + ) + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 040273e4..84092c44 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -7,14 +7,15 @@ import SwiftUI import Combine +import PhotosUI struct ProfileSettingView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: ProfileSettingViewModel + @ObservedObject private var viewModel: ProfileSettingViewModel - init(navigationRouter: NavigationRouter) { - self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel(navigationRouter: navigationRouter)) + init(viewModel: ProfileSettingViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } enum Field: Hashable { @@ -32,8 +33,30 @@ struct ProfileSettingView: View { rightButton: .none ) + if viewModel.isLoadingProfile { + VStack { + ProgressView() + .tint(.black) + Text("프로필 정보 로딩 중...") + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { VStack(spacing: 0) { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.point1) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.Codive.point4) + .cornerRadius(8) + .padding(.top, 16) + } + profileImageSection .padding(.top, 32) @@ -43,35 +66,31 @@ struct ProfileSettingView: View { completeButton .padding(.top, 120) } + } + .opacity(viewModel.isLoading ? 0.5 : 1) + .disabled(viewModel.isLoading) } } .background(Color.white) .navigationBarHidden(true) + .onAppear { + Task { + await viewModel.loadCurrentProfile() + } + } } private var profileImageSection: some View { ZStack(alignment: .bottomTrailing) { - Group { - if let pickedProfileImage = viewModel.pickedProfileImage { - pickedProfileImage - .resizable() - .scaledToFill() - } else { - Circle() - .fill(Color.Codive.grayscale6) - .overlay { - Image("settingProfile") - .resizable() - .scaledToFit() - } - } - } - .frame(width: 100, height: 100) - .clipShape(Circle()) + profileImageContent + .frame(width: 100, height: 100) + .clipShape(Circle()) - Button { - viewModel.onProfileImageTapped() - } label: { + PhotosPicker( + selection: $viewModel.selectedPhotoPickerItem, + matching: .images, + photoLibrary: .shared() + ) { Circle() .fill(Color.Codive.grayscale1) .frame(width: 28, height: 28) @@ -82,6 +101,11 @@ struct ProfileSettingView: View { } .buttonStyle(.plain) .offset(x: 6, y: 6) + .onChange(of: viewModel.selectedPhotoPickerItem) { newValue in + Task { + await viewModel.handlePhotoSelection(newValue) + } + } } .frame(maxWidth: .infinity) } @@ -142,6 +166,46 @@ struct ProfileSettingView: View { .padding(.horizontal, 20) } + private var profileImageContent: some View { + if let pickedProfileImage = viewModel.pickedProfileImage { + // 사용자가 방금 선택한 이미지 + return AnyView( + pickedProfileImage + .resizable() + .scaledToFill() + ) + } else if let imageUrl = viewModel.currentProfileImageUrl, !imageUrl.isEmpty { + // 저장된 프로필 이미지 URL + return AnyView( + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .empty: + ProgressView() + case .failure: + Image("Profile") + .resizable() + .scaledToFill() + @unknown default: + Image("Profile") + .resizable() + .scaledToFill() + } + } + ) + } else { + // 기본 이미지 (프로필 이미지가 없을 때) + return AnyView( + Image("Profile") + .resizable() + .scaledToFill() + ) + } + } + private var privacySection: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { @@ -183,9 +247,9 @@ struct ProfileSettingView: View { private var completeButton: some View { CustomButton( - text: "설정 완료", + text: viewModel.isLoading ? "저장 중..." : "설정 완료", widthType: .fixed, - isEnabled: viewModel.canComplete + isEnabled: viewModel.canComplete && !viewModel.isLoading ) { viewModel.onCompleteTapped() } @@ -194,5 +258,5 @@ struct ProfileSettingView: View { } #Preview { - ProfileSettingView(navigationRouter: NavigationRouter()) + EmptyView() } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index f45a7d4e..614deabb 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -10,11 +10,11 @@ import SwiftUI // MARK: - View struct ProfileView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: ProfileViewModel + @ObservedObject private var viewModel: ProfileViewModel - init(navigationRouter: NavigationRouter) { - self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: ProfileViewModel(navigationRouter: navigationRouter)) + init(viewModel: ProfileViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } var body: some View { @@ -24,7 +24,7 @@ struct ProfileView: View { profileSection .padding(.top, 32) - + Divider() .padding(.top, 24) .foregroundStyle(Color.Codive.grayscale7) @@ -39,6 +39,9 @@ struct ProfileView: View { } } .background(Color.white) + .task { + await viewModel.loadMyProfile() + } } // MARK: - Top Bar @@ -71,11 +74,36 @@ struct ProfileView: View { // MARK: - Profile private var profileSection: some View { VStack { - Image("CustomProfile") - .resizable() - .scaledToFill() - .frame(width: 80, height: 80) - .clipShape(Circle()) + if let profileImageUrl = viewModel.profileImageUrl, let url = URL(string: profileImageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + case .empty, .failure: + Image("Profile") + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + @unknown default: + Image("Profile") + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + } + } + } else { + Image("Profile") + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + } Text(viewModel.displayName) .font(.codive_title2) @@ -187,5 +215,5 @@ struct ProfileView: View { } #Preview { - ProfileView(navigationRouter: NavigationRouter()) + EmptyView() } diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift index 1e1e4ed9..7d21c73d 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -7,6 +7,9 @@ import SwiftUI import Combine +import Photos +import PhotosUI +import UIKit @MainActor final class ProfileSettingViewModel: ObservableObject { @@ -16,6 +19,8 @@ final class ProfileSettingViewModel: ObservableObject { // MARK: - Dependencies private let navigationRouter: NavigationRouter + private let updateProfileUseCase: UpdateProfileUseCase + private let profileRepository: ProfileRepository // MARK: - Published Properties @Published var nickname: String = "" { @@ -61,12 +66,21 @@ final class ProfileSettingViewModel: ObservableObject { } @Published var pickedProfileImage: Image? = nil + @Published var selectedPhotoPickerItem: PhotosPickerItem? = nil + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + @Published var currentProfileImageUrl: String? = nil @Published private(set) var canComplete: Bool = false + @Published var isLoadingProfile: Bool = false + + private var selectedImageData: Data? = nil // MARK: - Initializer - init(navigationRouter: NavigationRouter) { + init(navigationRouter: NavigationRouter, updateProfileUseCase: UpdateProfileUseCase, profileRepository: ProfileRepository) { self.navigationRouter = navigationRouter + self.updateProfileUseCase = updateProfileUseCase + self.profileRepository = profileRepository updateCanComplete() } @@ -124,21 +138,121 @@ final class ProfileSettingViewModel: ObservableObject { func runNicknameDuplicateCheck() { nicknameCheckStatus = .checking - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - let lowered = self.nickname.lowercased() - if lowered == "trendbox" || lowered == "ckj11" { - self.nicknameCheckStatus = .duplicated - } else { + Task { + do { + let isDuplicated = try await profileRepository.checkNicknameDuplicate(nickname: nickname) + DispatchQueue.main.async { + self.nicknameCheckStatus = isDuplicated ? .duplicated : .available + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "닉네임 중복확인 실패: \(error.localizedDescription)" + self.nicknameCheckStatus = .none + } + } + } + } + + func loadCurrentProfile() async { + isLoadingProfile = true + + do { + let profileInfo = try await profileRepository.fetchMyProfile() + DispatchQueue.main.async { + self.nickname = profileInfo.nickname + self.intro = profileInfo.introduction ?? "" + self.isPublic = true // API에서 공개여부 정보가 있으면 적용 self.nicknameCheckStatus = .available + + // 프로필 이미지 URL 저장 (null이면 기본 이미지 사용) + self.currentProfileImageUrl = profileInfo.profileImageUrl + + self.updateCanComplete() + self.isLoadingProfile = false + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "프로필 정보 로드 실패: \(error.localizedDescription)" + self.isLoadingProfile = false } } } func onProfileImageTapped() { - print("Profile image tapped") + requestPhotoLibraryAccess() + } + + private func requestPhotoLibraryAccess() { + PHPhotoLibrary.requestAuthorization { [weak self] status in + DispatchQueue.main.async { + switch status { + case .authorized, .limited: + self?.selectedPhotoPickerItem = nil + case .denied, .restricted: + self?.openAppSettings() + case .notDetermined: + break + @unknown default: + break + } + } + } + } + + private func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + + func handlePhotoSelection(_ item: PhotosPickerItem?) async { + guard let item = item else { return } + + do { + if let data = try await item.loadTransferable(type: Data.self) { + // 선택한 이미지 미리보기 표시 + if let uiImage = UIImage(data: data) { + DispatchQueue.main.async { + self.selectedImageData = data + self.pickedProfileImage = Image(uiImage: uiImage) + self.errorMessage = nil + } + } + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "이미지 선택 실패: \(error.localizedDescription)" + } + } } func onCompleteTapped() { - navigationRouter.navigateBack() + Task { + await submitProfileUpdate() + } + } + + private func submitProfileUpdate() async { + isLoading = true + errorMessage = nil + + defer { + isLoading = false + } + + do { + let _ = try await updateProfileUseCase.execute( + nickname: nickname, + bio: intro, + isPublic: isPublic, + imageData: selectedImageData + ) + + DispatchQueue.main.async { + self.navigationRouter.navigateBack() + } + } catch { + errorMessage = "프로필 수정 실패: \(error.localizedDescription)" + } } } diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index 7b9e8084..39dd18c4 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -9,42 +9,70 @@ import SwiftUI @MainActor class ProfileViewModel: ObservableObject { - // MARK: - Mock Data - @Published var username: String = "kiki01" - @Published var displayName: String = "일기러버" - @Published var introText: String = "안녕하세요 일기 러버에요" - @Published var followerCount: Int = 22 - @Published var followingCount: Int = 20 - + // MARK: - Profile Data + @Published var userId: Int = 0 + @Published var username: String = "" + @Published var displayName: String = "" + @Published var introText: String = "" + @Published var followerCount: Int = 0 + @Published var followingCount: Int = 0 + @Published var profileImageUrl: String? + // MARK: - State @Published var month: Date = Date() // 현재 표시 월 @Published var selectedDate: Date? = Date() // 선택된 날짜 - + @Published var isLoading: Bool = false + @Published var errorMessage: String? + // MARK: - Dependencies private let navigationRouter: NavigationRouter - + private let fetchMyProfileUseCase: FetchMyProfileUseCase + // MARK: - Initializer - init(navigationRouter: NavigationRouter) { + init(navigationRouter: NavigationRouter, fetchMyProfileUseCase: FetchMyProfileUseCase) { self.navigationRouter = navigationRouter + self.fetchMyProfileUseCase = fetchMyProfileUseCase } + // MARK: - Loading + func loadMyProfile() async { + isLoading = true + errorMessage = nil + + do { + let profileInfo = try await fetchMyProfileUseCase.execute() + self.userId = profileInfo.userId + self.username = profileInfo.nickname + self.displayName = profileInfo.displayName + self.introText = profileInfo.introduction ?? "" + self.followerCount = profileInfo.followerCount + self.followingCount = profileInfo.followingCount + self.profileImageUrl = profileInfo.profileImageUrl + } catch { + self.errorMessage = error.localizedDescription + print("프로필 로드 실패: \(error.localizedDescription)") + } + + isLoading = false + } + // MARK: - Actions func onEditProfileTapped() { - print("Edit profile tapped") + navigationRouter.navigate(to: .profileSetting) } - + func onSettingsTapped() { navigationRouter.navigate(to: .settings) } - + func onFollowerTapped() { - navigationRouter.navigate(to: .followList(mode: .followers)) + navigationRouter.navigate(to: .followList(mode: .followers, memberId: userId)) } - + func onFollowingTapped() { - navigationRouter.navigate(to: .followList(mode: .followings)) + navigationRouter.navigate(to: .followList(mode: .followings, memberId: userId)) } - + func onMoreFavoriteCodiTapped() { navigationRouter.navigate(to: .favoriteCodiList(showHeart: true)) } diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index 039a180e..c8f61772 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -10,11 +10,11 @@ import SwiftUI // MARK: - View struct OtherProfileView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: OtherProfileViewModel + @ObservedObject private var viewModel: OtherProfileViewModel - init(navigationRouter: NavigationRouter) { - self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: OtherProfileViewModel(navigationRouter: navigationRouter)) + init(viewModel: OtherProfileViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } var body: some View { @@ -87,7 +87,7 @@ struct OtherProfileView: View { // MARK: - Profile private var profileSection: some View { VStack { - Image("CustomProfile") + Image("Profile") .resizable() .scaledToFill() .frame(width: 80, height: 80) @@ -218,5 +218,5 @@ struct OtherProfileView: View { } #Preview { - OtherProfileView(navigationRouter: NavigationRouter()) + EmptyView() } diff --git a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift index 8b23c07f..42dfb787 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -48,11 +48,11 @@ class OtherProfileViewModel: ObservableObject { } func onFollowerTapped() { - navigationRouter.navigate(to: .followList(mode: .followers)) + navigationRouter.navigate(to: .followList(mode: .followers, memberId: 0)) // TODO: 실제 userId로 변경 필요 } - + func onFollowingTapped() { - navigationRouter.navigate(to: .followList(mode: .followings)) + navigationRouter.navigate(to: .followList(mode: .followings, memberId: 0)) // TODO: 실제 userId로 변경 필요 } func onFollowButtonTapped() { diff --git a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift index 943f74dd..d2b90253 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift @@ -9,11 +9,11 @@ import SwiftUI struct FollowListView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: FollowListViewModel + @ObservedObject private var viewModel: FollowListViewModel - init(mode: FollowListMode, navigationRouter: NavigationRouter) { - self.navigationRouter = navigationRouter - _viewModel = StateObject(wrappedValue: FollowListViewModel(mode: mode)) + init(viewModel: FollowListViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } var body: some View { @@ -44,5 +44,8 @@ struct FollowListView: View { } .background(Color.white) .navigationBarBackButtonHidden(true) + .task { + await viewModel.load() + } } } diff --git a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift index ae90b7e1..9235cec9 100644 --- a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift +++ b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift @@ -8,29 +8,43 @@ import Foundation import SwiftUI +@MainActor final class FollowListViewModel: ObservableObject { @Published private(set) var items: [FollowRowItem] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + let mode: FollowListMode + private let fetchFollowsUseCase: FetchFollowsUseCase + private let memberId: Int - init(mode: FollowListMode) { + init(mode: FollowListMode, memberId: Int, fetchFollowsUseCase: FetchFollowsUseCase) { self.mode = mode - load() + self.memberId = memberId + self.fetchFollowsUseCase = fetchFollowsUseCase } - func load() { - // 실제 구현에서는 mode에 따라 API 분기 + func load() async { + isLoading = true + errorMessage = nil + + do { + let result = try await fetchFollowsUseCase.execute( + memberId: memberId, + isFollowing: mode == .followings, + lastFollowId: nil, + size: 20 + ) - if mode == .followers { - items = [ - .init(user: .init(userId: 1, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: false), - .init(user: .init(userId: 2, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true) - ] - } else { - items = [ - .init(user: .init(userId: 3, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true), - .init(user: .init(userId: 4, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true) - ] + self.items = result.followers.map { user in + FollowRowItem(user: user, isFollowing: true) + } + } catch { + self.errorMessage = error.localizedDescription + print("팔로우 목록 로드 실패: \(error.localizedDescription)") } + + isLoading = false } func onTapButton(userId: UserID) { diff --git a/Codive/Features/Report/Presentation/View/ReportView.swift b/Codive/Features/Report/Presentation/View/ReportView.swift index 70fbc868..e176c49b 100644 --- a/Codive/Features/Report/Presentation/View/ReportView.swift +++ b/Codive/Features/Report/Presentation/View/ReportView.swift @@ -47,7 +47,7 @@ struct ReportView: View { Group { HStack { // 후에 실제 프로필 이미지 주입 - Image("CustomProfile") + Image("Profile") .resizable() .scaledToFill() .frame(width: 40, height: 40) diff --git a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift index b1f3c60e..23bc8104 100644 --- a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift +++ b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift @@ -8,13 +8,23 @@ import Foundation final class SearchDataSource { - + + // MARK: - Properties + + private let apiService: SearchAPIServiceProtocol + + // MARK: - Initializer + + init(apiService: SearchAPIServiceProtocol = SearchAPIService()) { + self.apiService = apiService + } + // MARK: - Fetch Methods (기존) - + func fetchUserName() -> SearchEntity { return SearchEntity(username: "코디브") } - + func fetchRecentSearchTags() -> [SearchTagEntity] { return [ SearchTagEntity(id: 1, text: "겨울"), @@ -23,7 +33,7 @@ final class SearchDataSource { SearchTagEntity(id: 4, text: "패션이 좋아요") ] } - + func fetchRecommendedNews() -> [NewsEntity] { return [ NewsEntity( @@ -38,123 +48,27 @@ final class SearchDataSource { ) ] } - - func fetchPosts(query: String) -> [PostEntity] { - let allPosts = getAllPosts() - - if query.isEmpty || query == "전체" { - return allPosts - } - - let lowercasedQuery = query.lowercased() - return allPosts.filter { post in - let matchNickname = post.nickname.lowercased().contains(lowercasedQuery) - let matchDescription = post.description?.lowercased().contains(lowercasedQuery) ?? false - return matchNickname || matchDescription - } + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] { + let sortParam = sort == "인기순" ? "POPULAR" : (sort == "최신순" ? "LATEST" : nil) + let result = try await apiService.searchHistories( + keyword: query, + page: 0, + size: 10, + sort: sortParam + ) + return result.posts } - + // MARK: - Fetch Methods (계정용 추가) - - /// 검색어를 기준으로 유저 목록을 필터링해서 반환 - func fetchUsers(query: String) -> [SimpleUser] { - let allUsers = getAllUsers() - - // 전체 or 빈 문자열이면 전부 리턴 - if query.isEmpty || query == "전체" { - return allUsers - } - - let lowercasedQuery = query.lowercased() - - return allUsers.filter { user in - user.nickname.lowercased().contains(lowercasedQuery) - || user.handle.lowercased().contains(lowercasedQuery) - } - } - - // MARK: - Private Methods (공통) - - private func createDate(year: Int, month: Int, day: Int) -> Date { - var components = DateComponents() - components.year = year - components.month = month - components.day = day - return Calendar.current.date(from: components) ?? Date() - } - - // MARK: - Private Methods (게시글 더미) - - private func getAllPosts() -> [PostEntity] { - return [ - PostEntity( - id: 1, - postImageUrl: "https://picsum.photos/id/1018/162/216", - profileImageUrl: "https://picsum.photos/id/237/28/28", - nickname: "유저A", - likes: 150, - date: createDate(year: 2025, month: 11, day: 19), - description: "가을 자켓 코디" - ), - PostEntity( - id: 2, - postImageUrl: "https://picsum.photos/id/1019/162/216", - profileImageUrl: nil, - nickname: "유저B", - likes: 80, - date: createDate(year: 2025, month: 11, day: 17), - description: "겨울 드뮤어룩" - ), - PostEntity( - id: 3, - postImageUrl: "https://picsum.photos/id/1020/162/216", - profileImageUrl: "https://picsum.photos/id/100/28/28", - nickname: "유저C", - likes: 250, - date: createDate(year: 2025, month: 11, day: 18), - description: "데일리룩 추천" - ), - PostEntity( - id: 4, - postImageUrl: "https://picsum.photos/id/1021/162/216", - profileImageUrl: nil, - nickname: "유저D", - likes: 50, - date: createDate(year: 2025, month: 11, day: 19), - description: "드뮤어룩 첼시부츠" - ) - ] - } - - // MARK: - Private Methods (유저 더미) - - private func getAllUsers() -> [SimpleUser] { - return [ - SimpleUser( - userId: 1, - nickname: "코디브 공식", - handle: "@codive_official", - avatarURL: URL(string: "https://picsum.photos/id/200/80/80") - ), - SimpleUser( - userId: 2, - nickname: "한금준", - handle: "@geumjoon", - avatarURL: URL(string: "https://picsum.photos/id/201/80/80") - ), - SimpleUser( - userId: 3, - nickname: "드뮤어룩 장인", - handle: "@demure_master", - avatarURL: URL(string: "https://picsum.photos/id/202/80/80") - ), - SimpleUser( - userId: 4, - nickname: "한강러버", - handle: "@hanriver_lover", - avatarURL: nil - ) - ] + + /// 검색어를 기준으로 유저 목록을 API로 검색 + func fetchUsers(query: String) async throws -> [SimpleUser] { + let result = try await apiService.searchUsers( + keyword: query, + page: 0, + size: 10 + ) + return result.users } } diff --git a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift index 4e324d4e..0a6eda27 100644 --- a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift +++ b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift @@ -28,11 +28,11 @@ final class SearchRepositoryImpl: SearchRepository { return datasource.fetchRecommendedNews() } - func fetchPosts(query: String) -> [PostEntity] { - return datasource.fetchPosts(query: query) + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] { + return try await datasource.fetchPosts(query: query, sort: sort) } - - func fetchUsers(query: String) -> [SimpleUser] { - return datasource.fetchUsers(query: query) + + func fetchUsers(query: String) async throws -> [SimpleUser] { + return try await datasource.fetchUsers(query: query) } } diff --git a/Codive/Features/Search/Data/SearchAPIService.swift b/Codive/Features/Search/Data/SearchAPIService.swift new file mode 100644 index 00000000..78d4683a --- /dev/null +++ b/Codive/Features/Search/Data/SearchAPIService.swift @@ -0,0 +1,149 @@ +// +// SearchAPIService.swift +// Codive +// +// Created by Claude on 1/23/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Search API Service Protocol + +protocol SearchAPIServiceProtocol { + func searchUsers(keyword: String, page: Int64, size: Int32) async throws -> SearchUserResult + func searchHistories(keyword: String, page: Int64, size: Int32, sort: String?) async throws -> SearchHistoryResult +} + +// MARK: - Supporting Types + +struct SearchUserResult { + let users: [SimpleUser] + let isLast: Bool +} + +struct SearchHistoryResult { + let posts: [PostEntity] + let isLast: Bool +} + +// MARK: - Search API Service Implementation + +final class SearchAPIService: SearchAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + // MARK: - Search Users + + func searchUsers(keyword: String, page: Int64, size: Int32) async throws -> SearchUserResult { + let input = Operations.Search_searchUserByClokeyIdAndNickname.Input( + query: .init( + keyword: keyword, + page: page, + size: size + ) + ) + + let response = try await client.Search_searchUserByClokeyIdAndNickname(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseSearchedMemberResponse.self, + from: data + ) + + let members = decoded.result?.content ?? [] + let users: [SimpleUser] = members.compactMap { member -> SimpleUser? in + guard let userId = member.memberId else { return nil } + return SimpleUser( + userId: Int(userId), + nickname: member.nickname ?? "", + handle: member.nickname ?? "", + avatarURL: member.profileImageUrl.flatMap { URL(string: $0) } + ) + } + + return SearchUserResult( + users: users, + isLast: decoded.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, _): + throw SearchAPIError.serverError(statusCode: code, message: "유저 검색 실패") + } + } + + // MARK: - Search Histories + + func searchHistories(keyword: String, page: Int64, size: Int32, sort: String?) async throws -> SearchHistoryResult { + let sortPayload = sort.flatMap { sortStr in + Operations.Search_searchHistoryByHashtagsAndCategories.Input.Query.sortPayload(rawValue: sortStr) + } + + let input = Operations.Search_searchHistoryByHashtagsAndCategories.Input( + query: .init( + keyword: keyword, + page: page, + size: size, + sort: sortPayload + ) + ) + + let response = try await client.Search_searchHistoryByHashtagsAndCategories(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseSearchedHistoryResponse.self, + from: data + ) + + let histories = decoded.result?.content ?? [] + let posts: [PostEntity] = histories.compactMap { history -> PostEntity? in + guard let historyId = history.historyId else { return nil } + return PostEntity( + id: Int(historyId), + postImageUrl: history.historyImageUrl, + profileImageUrl: history.profileImageUrl, + nickname: history.nickname ?? "", + likes: 0, + date: Date(), + description: nil + ) + } + + return SearchHistoryResult( + posts: posts, + isLast: decoded.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, _): + throw SearchAPIError.serverError(statusCode: code, message: "해시태그 검색 실패") + } + } +} + +// MARK: - Search API Error + +enum SearchAPIError: LocalizedError { + case serverError(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + } + } +} diff --git a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift index 7b8fc9dd..622fafd4 100644 --- a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift +++ b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift @@ -9,6 +9,6 @@ protocol SearchRepository { func fetchUserName() -> SearchEntity func fetchRecentSearchTags() -> [SearchTagEntity] func fetchRecommendedNews() -> [NewsEntity] - func fetchPosts(query: String) -> [PostEntity] - func fetchUsers(query: String) -> [SimpleUser] + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] + func fetchUsers(query: String) async throws -> [SimpleUser] } diff --git a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift index 0290d4ef..69ba28a8 100644 --- a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift +++ b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift @@ -28,11 +28,11 @@ final class SearchUseCase { return repository.fetchRecommendedNews() } - func fetchPosts(query: String) -> [PostEntity] { - return repository.fetchPosts(query: query) + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] { + return try await repository.fetchPosts(query: query, sort: sort) } - - func fetchUsers(query: String) -> [SimpleUser] { - return repository.fetchUsers(query: query) + + func fetchUsers(query: String) async throws -> [SimpleUser] { + return try await repository.fetchUsers(query: query) } } diff --git a/Codive/Features/Search/Presentation/Component/PostCard.swift b/Codive/Features/Search/Presentation/Component/PostCard.swift index a6a3cc67..95b73701 100644 --- a/Codive/Features/Search/Presentation/Component/PostCard.swift +++ b/Codive/Features/Search/Presentation/Component/PostCard.swift @@ -12,10 +12,8 @@ struct PostCard: View { let postImageUrl: String? let profileImageUrl: String? let nickname: String - + // MARK: - Constants (디자인 값) - private let cardWidth: CGFloat = 162 - private let cardHeight: CGFloat = 216 private let cornerRadius: CGFloat = 12 private let profileImageSize: CGFloat = 28 private let nicknameFont = Font.codive_body2_medium @@ -39,16 +37,11 @@ struct PostCard: View { ProgressView() } } - .frame(width: cardWidth, height: cardHeight) .clipped() - .cornerRadius(cornerRadius) } else { Rectangle() .fill(Color.gray.opacity(0.3)) .overlay(Image(systemName: "photo").foregroundStyle(Color.gray)) - .frame(width: cardWidth, height: cardHeight) - .clipped() - .cornerRadius(cornerRadius) } HStack(spacing: 4) { if let urlString = profileImageUrl, let url = URL(string: urlString) { @@ -58,10 +51,9 @@ struct PostCard: View { .resizable() .aspectRatio(contentMode: .fill) } else if phase.error != nil { - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) } else { ProgressView() } @@ -69,10 +61,9 @@ struct PostCard: View { .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } else { - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } @@ -85,7 +76,8 @@ struct PostCard: View { .padding(.leading, overlayLeft) .padding(.bottom, overlayBottom) } - .frame(width: cardWidth, height: cardHeight) + .aspectRatio(3/4, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } } diff --git a/Codive/Features/Search/Presentation/View/HashtagView.swift b/Codive/Features/Search/Presentation/View/HashtagView.swift index 33457ab9..99846b4d 100644 --- a/Codive/Features/Search/Presentation/View/HashtagView.swift +++ b/Codive/Features/Search/Presentation/View/HashtagView.swift @@ -10,7 +10,13 @@ import SwiftUI // MARK: - Hashtag View struct HashtagView: View { @ObservedObject var viewModel: SearchResultViewModel - + + // MARK: - Grid Columns + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 11), + GridItem(.flexible(), spacing: 11) + ] + // MARK: - Computed Properties private var sortOptionsString: [String] { viewModel.sortOptions.map { $0.displayName } @@ -35,13 +41,17 @@ struct HashtagView: View { .padding(.top, 18) .zIndex(10) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 11) { + LazyVGrid(columns: columns, spacing: 16) { ForEach(viewModel.posts) { post in - PostCard( - postImageUrl: post.postImageUrl, - profileImageUrl: post.profileImageUrl, - nickname: post.nickname - ) + Button { + viewModel.navigateToFeedDetail(feedId: post.id) + } label: { + PostCard( + postImageUrl: post.postImageUrl, + profileImageUrl: post.profileImageUrl, + nickname: post.nickname + ) + } } } .padding(.top, 18) diff --git a/Codive/Features/Search/Presentation/View/SearchResultView.swift b/Codive/Features/Search/Presentation/View/SearchResultView.swift index ed404b59..9373ca03 100644 --- a/Codive/Features/Search/Presentation/View/SearchResultView.swift +++ b/Codive/Features/Search/Presentation/View/SearchResultView.swift @@ -49,12 +49,17 @@ struct SearchResultView: View { if selectedSegment == .account { VStack(spacing: 0) { ForEach(viewModel.users, id: \.userId) { user in - CustomUserRow( - user: user, - buttonStyle: .none - ) { - // 버튼 동작 + Button { + viewModel.navigateToUserProfile(userId: Int(user.userId)) + } label: { + CustomUserRow( + user: user, + buttonStyle: .none + ) { + // 버튼 동작 + } } + .padding(.vertical, 6) } } .padding(.top, 18) diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift index 0c574931..a60a98a1 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift @@ -46,62 +46,69 @@ final class SearchResultViewModel: ObservableObject { $currentSort .removeDuplicates() .sink { [weak self] newSort in - self?.applySorting(newSort: newSort) + Task { + await self?.loadPosts() + } } .store(in: &cancellables) } - - private func applySorting(newSort: String) { - switch newSort { - case "인기순": - self.posts = self.allPosts.sorted { $0.likes > $1.likes } - case "최신순": - self.posts = self.allPosts.sorted { $0.date > $1.date } - case "전체": - self.posts = self.allPosts - default: - break - } - print("정렬 적용 완료: \(newSort), 결과 \(self.posts.count)개") - } // MARK: - Public Methods func loadInitialData() { - loadPosts() - loadUsers() + Task { + await loadPosts() + await loadUsers() + } } - - func loadPosts() { - self.allPosts = useCase.fetchPosts(query: self.initialQuery) - self.posts = self.allPosts - self.applySorting(newSort: self.currentSort) + + func loadPosts() async { + do { + let sort = currentSort == "전체" ? nil : currentSort + self.posts = try await useCase.fetchPosts(query: self.initialQuery, sort: sort) + } catch { + print("게시물 로딩 실패: \(error.localizedDescription)") + } } - - func loadUsers() { - self.allUsers = useCase.fetchUsers(query: self.initialQuery) - self.users = self.allUsers - print("유저 로딩 완료: \(self.users.count)명") + + func loadUsers() async { + do { + self.allUsers = try await useCase.fetchUsers(query: self.initialQuery) + self.users = self.allUsers + print("유저 로딩 완료: \(self.users.count)명") + } catch { + print("유저 로딩 실패: \(error.localizedDescription)") + } } - + func executeNewSearch(query: String) { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedQuery.isEmpty { print("검색어를 입력해 주세요.") return } - + self.initialQuery = trimmedQuery self.currentSort = "전체" - loadPosts() - loadUsers() - - print("현재 페이지에서 검색 결과 갱신: \(trimmedQuery)") + Task { + await loadPosts() + await loadUsers() + print("현재 페이지에서 검색 결과 갱신: \(trimmedQuery)") + } } // MARK: - Navigation + func handleBackTap() { navigationRouter.navigateBack() } + + func navigateToUserProfile(userId: Int) { + navigationRouter.navigate(to: .otherProfile(userId: userId)) + } + + func navigateToFeedDetail(feedId: Int) { + navigationRouter.navigate(to: .feedDetail(feedId: feedId)) + } } diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index e578f194..3c160447 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -18,8 +18,7 @@ struct SettingView: View { var body: some View { VStack(spacing: 0) { CustomNavigationBar(title: TextLiteral.Setting.title) { - // 뒤로가기 액션 - print("뒤로가기") + vm.navigateBack() } ScrollView { @@ -33,6 +32,7 @@ struct SettingView: View { .padding(.top, 24) } } + .navigationBarHidden(true) .task { await vm.load() } @@ -171,16 +171,16 @@ private struct SettingRow: View { let text: String var body: some View { - HStack { + HStack(spacing: 12) { Text(text) .font(.codive_body1_regular) .foregroundStyle(Color.Codive.grayscale1) Spacer() - Image("backSmall") - .frame(width: 6, height: 12) - .foregroundStyle(Color.Codive.main1) + Image(systemName: "chevron.right") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale3) } } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index cf0985df..4deac0ba 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -66,4 +66,9 @@ final class SettingViewModel: ObservableObject { self.error = error } } + + // MARK: - Navigation + func navigateBack() { + navigationRouter.navigateBack() + } } diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/Contents.json similarity index 71% rename from Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json rename to Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/Contents.json index 1216e975..ed27c2cf 100644 --- a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json +++ b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "프로필.png", + "filename" : "profile.png", "idiom" : "universal" } ], diff --git "a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png similarity index 100% rename from "Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" rename to Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index 0d99120b..4b645c6a 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -41,11 +41,13 @@ enum AppDestination: Hashable, Identifiable { case feedDetail(feedId: Int) case comment(feedId: Int) case favoriteCodiList(showHeart: Bool) - case followList(mode: FollowListMode) - + case followList(mode: FollowListMode, memberId: Int) + case otherProfile(userId: Int) + case myCloset case clothDetail(cloth: Cloth) case clothEdit(cloth: Cloth) + case profileSetting var id: Self { self } @@ -77,9 +79,9 @@ enum AppDestination: Hashable, Identifiable { return true // Profile Flow - case .favoriteCodiList, .settings, .followList: + case .favoriteCodiList, .settings, .followList, .profileSetting: return true - + // Closet Flow - 전체 화면 case .myCloset, .clothDetail, .clothEdit: return true @@ -115,9 +117,9 @@ enum AppDestination: Hashable, Identifiable { return false // Profile Flow - 자체 네비게이션 바 있음 - case .favoriteCodiList, .settings, .followList: + case .favoriteCodiList, .settings, .followList, .profileSetting: return false - + // Closet Flow - 자체 네비게이션 바 있음 case .myCloset, .clothDetail, .clothEdit: return false diff --git a/Codive/Router/ViewFactory/FeedViewFactory.swift b/Codive/Router/ViewFactory/FeedViewFactory.swift index 8f7dae0b..1f998cf2 100644 --- a/Codive/Router/ViewFactory/FeedViewFactory.swift +++ b/Codive/Router/ViewFactory/FeedViewFactory.swift @@ -10,10 +10,12 @@ import SwiftUI @MainActor final class FeedViewFactory { private weak var feedDIContainer: FeedDIContainer? + private let navigationRouter: NavigationRouter // MARK: - Initializer - init(feedDIContainer: FeedDIContainer) { + init(feedDIContainer: FeedDIContainer, navigationRouter: NavigationRouter) { self.feedDIContainer = feedDIContainer + self.navigationRouter = navigationRouter } // MARK: - Methods @@ -22,6 +24,15 @@ final class FeedViewFactory { switch destination { case .feedDetail(let feedId): feedDIContainer?.makeFeedDetailView(feedId: feedId) + case .otherProfile: + if let profileDIContainer = feedDIContainer?.profileDIContainer { + OtherProfileView( + viewModel: profileDIContainer.makeOtherProfileViewModel(), + navigationRouter: navigationRouter + ) + } else { + EmptyView() + } default: EmptyView() } diff --git a/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift b/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift index c4081d0c..fd4ff177 100644 --- a/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift +++ b/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift @@ -8,11 +8,11 @@ import SwiftUI struct FeedFilterBar: View { - + // MARK: - Properties @Binding var isFollowingSelected: Bool let categories: [String] - @Binding var selectedCategory: String + @Binding var selectedCategory: Set var onFilterTap: () -> Void // MARK: - Body @@ -25,16 +25,16 @@ struct FeedFilterBar: View { // 스타일 카테고리 버튼 ForEach(categories, id: \.self) { category in - let isSelected = selectedCategory == category - + let isSelected = selectedCategory.contains(category) + FilterSelectionButton( title: category, isSelected: isSelected ) { - if selectedCategory == category { - selectedCategory = "" // Deselect + if selectedCategory.contains(category) { + selectedCategory.remove(category) } else { - selectedCategory = category + selectedCategory.insert(category) } } } @@ -134,13 +134,13 @@ private struct FilterSelectionButton: View { FeedFilterBar( isFollowingSelected: .constant(true), categories: ["미니멀", "캐주얼", "스트릿", "빈티지"], - selectedCategory: .constant("") + selectedCategory: .constant([]) ) {} - + FeedFilterBar( isFollowingSelected: .constant(false), categories: ["미니멀", "캐주얼", "스트릿", "빈티지"], - selectedCategory: .constant("미니멀") + selectedCategory: .constant(["미니멀", "캐주얼"]) ) {} } } diff --git a/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift b/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift index d160442f..1252c46d 100644 --- a/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift +++ b/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift @@ -55,7 +55,7 @@ struct CustomProductBottomSheet: View { LazyVGrid(columns: columns, spacing: 10) { ForEach(products.sorted { $0.isTodayCloth && !$1.isTodayCloth }) { product in CustomProductCard( - imageName: product.imageName, + imageName: product.imageUrl ?? product.imageName ?? "", isTodayCloth: product.isTodayCloth, isSelected: selectedProducts.contains(product.id) ) { diff --git a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift index d718a009..65011242 100644 --- a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift +++ b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift @@ -18,14 +18,26 @@ struct CustomFeedCard: View { // MARK: - Body var body: some View { ZStack(alignment: .bottomLeading) { - + // 메인 배경 이미지 Rectangle() .fill(Color.gray) .overlay( - Image(imageUrl) - .resizable() - .scaledToFill() + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + Image(systemName: "photo") + .foregroundColor(.gray) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } ) .overlay( LinearGradient( @@ -34,7 +46,7 @@ struct CustomFeedCard: View { endPoint: .bottom ) ) - + // 좋아요 버튼 (우측 상단) VStack { HStack { @@ -52,23 +64,33 @@ struct CustomFeedCard: View { } .padding(15) .frame(maxWidth: .infinity, maxHeight: .infinity) - + // 프로필 정보 - HStack { + HStack(spacing: 8) { // 프로필 이미지 - Image(profileImageUrl) - .resizable() - .scaledToFill() - .frame(width: 28, height: 28) - .clipShape(Circle()) - .overlay( - Circle() - ) - + AsyncImage(url: URL(string: profileImageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure, .empty: + Image("Profile") + .resizable() + .scaledToFill() + @unknown default: + EmptyView() + } + } + .frame(width: 28, height: 28) + .clipShape(Circle()) + // 닉네임 Text(nickname) .font(.codive_body2_medium) .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) } .padding(.leading, 15) .padding(.bottom, 15) diff --git a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift index 3c178a06..b0468bf1 100644 --- a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift +++ b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift @@ -58,11 +58,17 @@ struct CustomUserRow: View { .resizable() .scaledToFill() case .empty: - Color.Codive.grayscale4 + Image("Profile") + .resizable() + .scaledToFill() case .failure: - Color.Codive.grayscale4 + Image("Profile") + .resizable() + .scaledToFill() @unknown default: - Color.Codive.grayscale4 + Image("Profile") + .resizable() + .scaledToFill() } } .frame(width: 40, height: 40) @@ -73,9 +79,11 @@ struct CustomUserRow: View { Text(user.nickname) .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) - Text(user.handle) - .font(.codive_body3_medium) - .foregroundStyle(Color.Codive.grayscale3) + if !user.handle.isEmpty { + Text(user.handle) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale3) + } } .padding(.leading, 8) diff --git a/Codive/Shared/Domain/Entities/ClothTag.swift b/Codive/Shared/Domain/Entities/ClothTag.swift index 69294aa7..555b7b8d 100644 --- a/Codive/Shared/Domain/Entities/ClothTag.swift +++ b/Codive/Shared/Domain/Entities/ClothTag.swift @@ -14,6 +14,7 @@ struct ClothTag: Identifiable, Equatable, Hashable { let clothId: Int let brand: String let name: String + let imageUrl: String? var locationX: CGFloat var locationY: CGFloat diff --git a/Codive/Shared/Domain/Entities/Comment.swift b/Codive/Shared/Domain/Entities/Comment.swift index 7f8b572c..d7dca3c9 100644 --- a/Codive/Shared/Domain/Entities/Comment.swift +++ b/Codive/Shared/Domain/Entities/Comment.swift @@ -6,16 +6,17 @@ // import Foundation +import CodiveAPI public struct Comment: Identifiable, Equatable { public let id: Int public let content: String public let author: User public let isMine: Bool - + // 댓글(Parent) 전용 필드 public let hasReplies: Bool - + // UI 상태 관리를 위한 필드 public var replies: [Comment]? @@ -35,3 +36,42 @@ public struct Comment: Identifiable, Equatable { self.replies = replies } } + +// MARK: - API 매핑 +extension Comment { + /// CodiveAPI의 CommentListResponse를 Comment로 변환 + static func from(apiResponse: Components.Schemas.CommentListResponse) -> Comment { + let user = User( + id: String(apiResponse.memberId ?? 0), + nickname: apiResponse.nickname ?? "", + profileImageUrl: apiResponse.profileImageUrl + ) + + return Comment( + id: Int(apiResponse.commentId ?? 0), + content: apiResponse.content ?? "", + author: user, + isMine: apiResponse.isMine ?? false, + hasReplies: apiResponse.replied ?? false, + replies: [] + ) + } + + /// CodiveAPI의 ReplyListResponse를 Comment로 변환 + static func from(apiResponse: Components.Schemas.ReplyListResponse) -> Comment { + let user = User( + id: String(apiResponse.memberId ?? 0), + nickname: apiResponse.nickname ?? "", + profileImageUrl: apiResponse.profileImageUrl + ) + + return Comment( + id: Int(apiResponse.replyId ?? 0), + content: apiResponse.content ?? "", + author: user, + isMine: apiResponse.isMine ?? false, + hasReplies: false, + replies: [] + ) + } +} diff --git a/Codive/Shared/Domain/Entities/Feed.swift b/Codive/Shared/Domain/Entities/Feed.swift index 87255d79..38773ee1 100644 --- a/Codive/Shared/Domain/Entities/Feed.swift +++ b/Codive/Shared/Domain/Entities/Feed.swift @@ -16,6 +16,7 @@ public struct Feed: Identifiable, Equatable { public let situationId: Int? public let styleIds: [Int]? + public let styleNames: [String]? public let hashtags: [String]? public let createdAt: Date? @@ -30,6 +31,7 @@ public struct Feed: Identifiable, Equatable { images: [FeedImage], situationId: Int? = nil, styleIds: [Int]? = nil, + styleNames: [String]? = nil, hashtags: [String]? = nil, createdAt: Date? = nil, likeCount: Int? = nil, @@ -42,6 +44,7 @@ public struct Feed: Identifiable, Equatable { self.images = images self.situationId = situationId self.styleIds = styleIds + self.styleNames = styleNames self.hashtags = hashtags self.createdAt = createdAt self.likeCount = likeCount @@ -53,10 +56,12 @@ public struct Feed: Identifiable, Equatable { // MARK: - FeedImage public struct FeedImage: Identifiable, Equatable { public let id: UUID = UUID() + public let imageId: Int64? public let imageUrl: String public let tags: [ImageClothTag] - - public init(imageUrl: String, tags: [ImageClothTag] = []) { + + public init(imageId: Int64? = nil, imageUrl: String, tags: [ImageClothTag] = []) { + self.imageId = imageId self.imageUrl = imageUrl self.tags = tags } diff --git a/Project.swift b/Project.swift index 167f9397..900b0fa4 100644 --- a/Project.swift +++ b/Project.swift @@ -107,7 +107,8 @@ let project = Project( "storykompassauth", "kakaolink", "kakaotalk-5.9.7" - ] + ], + ] ), sources: [ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 857e0670..4a06c42f 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "124c84772a8a0199b93c4ac81f2b98dc926f6430" + "revision" : "dfde09be34ac830a9efc93efd52296ff61c7fda2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ReactiveX/RxSwift.git", "state" : { - "revision" : "5004a18539bd68905c5939aa893075f578f4f03d", - "version" : "6.9.1" + "revision" : "b529ef9b7fa3ba2b38d299df23fc2165f21abbb9", + "version" : "6.10.0" } }, {